diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 562e5bc..7959d1c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -13,11 +13,31 @@ on: required: false default: "scripts/ci/run-release-verification.sh" type: string + version-script: + description: Optional release version validation/update script path relative to the repository root + required: false + default: "scripts/ci/run-release-version.sh" + type: string + build-script: + description: Optional release artifact build script path relative to the repository root + required: false + default: "scripts/ci/run-release-build.sh" + type: string publish-script: description: Release publish script path relative to the repository root required: false default: "scripts/ci/run-release-publish.sh" type: string + release-notes-file: + description: Optional release notes file path produced before GitHub Release creation + required: false + default: "dist/release/RELEASE_NOTES.md" + type: string + default-branch: + description: Default release branch for new minor and major releases + required: false + default: "main" + type: string create-github-release: description: Create a GitHub release after publish succeeds required: false @@ -52,6 +72,57 @@ jobs: - uses: actions/checkout@v4 with: fetch-depth: 0 + - name: Derive release metadata + id: release_meta + env: + RELEASE_TAG: ${{ github.ref_name }} + TAG_PREFIX: ${{ inputs.tag-prefix }} + DEFAULT_BRANCH: ${{ inputs.default-branch }} + run: | + set -euo pipefail + + prefix="${TAG_PREFIX}" + tag="${RELEASE_TAG}" + escaped_prefix="$(printf '%s' "${prefix}" | sed 's/[][(){}.^$*+?|\\-]/\\&/g')" + + semver_component='(0|[1-9][0-9]*)' + if [[ ! "${tag}" =~ ^${escaped_prefix}${semver_component}\.${semver_component}\.${semver_component}$ ]]; then + echo "Release tag '${tag}' does not match the required exact SemVer pattern '${prefix}X.Y.Z'." >&2 + exit 1 + fi + + major="${BASH_REMATCH[1]}" + minor="${BASH_REMATCH[2]}" + patch="${BASH_REMATCH[3]}" + commit="$(git rev-list -n 1 "${tag}")" + + git fetch origin "+refs/heads/*:refs/remotes/origin/*" --prune + + default_ref="origin/${DEFAULT_BRANCH}" + maintenance_ref="origin/release/${major}.${minor}" + allowed=false + if git merge-base --is-ancestor "${commit}" "${default_ref}"; then + allowed=true + elif git show-ref --verify --quiet "refs/remotes/${maintenance_ref}" && + git merge-base --is-ancestor "${commit}" "${maintenance_ref}"; then + allowed=true + fi + + if [[ "${allowed}" != "true" ]]; then + echo "Release tag '${tag}' must point at ${default_ref} or ${maintenance_ref}." >&2 + exit 1 + fi + + previous_tag="$(git tag --list "${prefix}[0-9]*.[0-9]*.[0-9]*" --sort=-v:refname | grep -v "^${tag}$" | head -n 1 || true)" + + { + echo "tag=${tag}" + echo "major=${major}" + echo "minor=${minor}" + echo "patch=${patch}" + echo "commit=${commit}" + echo "previous_tag=${previous_tag}" + } >> "${GITHUB_OUTPUT}" - name: Verify release contract run: | if [[ ! -x "${{ inputs.verify-script }}" ]]; then @@ -59,6 +130,20 @@ jobs: exit 1 fi bash "${{ inputs.verify-script }}" + - name: Run release version hook + run: | + if [[ -x "${{ inputs.version-script }}" ]]; then + bash "${{ inputs.version-script }}" + else + echo "No executable release version hook is configured: ${{ inputs.version-script }}" + fi + - name: Build release artifacts + run: | + if [[ -x "${{ inputs.build-script }}" ]]; then + bash "${{ inputs.build-script }}" + else + echo "No executable release artifact build hook is configured: ${{ inputs.build-script }}" + fi - name: Publish release artifacts run: | if [[ ! -x "${{ inputs.publish-script }}" ]]; then @@ -66,9 +151,27 @@ jobs: exit 1 fi bash "${{ inputs.publish-script }}" + - name: Create GitHub release + if: ${{ inputs.create-github-release }} + env: + GH_TOKEN: ${{ github.token }} + run: | + tag="${GITHUB_REF_NAME}" + if [[ -z "${tag}" ]]; then + echo "GITHUB_REF_NAME is required to create a GitHub release." >&2 + exit 1 + fi + + if [[ -s "${{ inputs.release-notes-file }}" ]]; then + gh release create "${tag}" --notes-file "${{ inputs.release-notes-file }}" || + gh release edit "${tag}" --notes-file "${{ inputs.release-notes-file }}" --latest + else + gh release create "${tag}" --generate-notes || + gh release edit "${tag}" --latest + fi - name: Promote floating SemVer tags env: - RELEASE_TAG: ${{ github.ref_name }} + RELEASE_TAG: ${{ steps.release_meta.outputs.tag }} TAG_PREFIX: ${{ inputs.tag-prefix }} UPDATE_MAJOR_TAG: ${{ inputs.update-major-tag }} UPDATE_MINOR_TAG: ${{ inputs.update-minor-tag }} @@ -79,7 +182,8 @@ jobs: tag="${RELEASE_TAG}" escaped_prefix="$(printf '%s' "${prefix}" | sed 's/[][(){}.^$*+?|\\-]/\\&/g')" - if [[ ! "${tag}" =~ ^${escaped_prefix}([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then + semver_component='(0|[1-9][0-9]*)' + if [[ ! "${tag}" =~ ^${escaped_prefix}${semver_component}\.${semver_component}\.${semver_component}$ ]]; then echo "Release tag '${tag}' does not match the required exact SemVer pattern '${prefix}X.Y.Z'." >&2 exit 1 fi @@ -102,14 +206,3 @@ jobs: git tag -fa "${major_tag}" "${commit}" -m "Advance ${major_tag} to ${tag}" git push origin "refs/tags/${major_tag}" --force fi - - name: Create GitHub release - if: ${{ inputs.create-github-release }} - env: - GH_TOKEN: ${{ github.token }} - run: | - tag="${GITHUB_REF_NAME}" - if [[ -z "${tag}" ]]; then - echo "GITHUB_REF_NAME is required to create a GitHub release." >&2 - exit 1 - fi - gh release create "${tag}" --generate-notes || gh release edit "${tag}" --latest diff --git a/src/archetypes.ts b/src/archetypes.ts index 21efbba..7186710 100644 --- a/src/archetypes.ts +++ b/src/archetypes.ts @@ -905,6 +905,14 @@ ${indentBlock(projectIdentityLines(manifest), 4)} } function fastChecksScript(manifest: BootstrapManifest): string { + if (manifest.ci.customScripts.fast) { + return `${dedent` + #!/usr/bin/env bash + set -euo pipefail + + `}\n${manifest.ci.customScripts.fast.trimEnd()}\n`; + } + const body = manifest.archetype.kind === "python-service" ? pythonFastChecks() @@ -920,6 +928,14 @@ function fastChecksScript(manifest: BootstrapManifest): string { } function extendedChecksScript(manifest: BootstrapManifest): string { + if (manifest.ci.customScripts.extended) { + return `${dedent` + #!/usr/bin/env bash + set -euo pipefail + + `}\n${manifest.ci.customScripts.extended.trimEnd()}\n`; + } + const body = manifest.archetype.kind === "python-service" ? pythonExtendedChecks() @@ -934,7 +950,15 @@ function extendedChecksScript(manifest: BootstrapManifest): string { `}\n${body}\n`; } -function releaseVerificationScript(): string { +function releaseVerificationScript(manifest: BootstrapManifest): string { + if (manifest.ci.customScripts.releaseVerification) { + return `${dedent` + #!/usr/bin/env bash + set -euo pipefail + + `}\n${manifest.ci.customScripts.releaseVerification.trimEnd()}\n`; + } + return `${dedent` #!/usr/bin/env bash set -euo pipefail @@ -1096,32 +1120,44 @@ function workflowPaths(manifest: BootstrapManifest): { app: string[]; ci: string "docs/bootstrap/**" ]; + let paths: { app: string[]; ci: string[]; extended: string[] }; + switch (manifest.archetype.kind) { case "nextjs-web": - return { + paths = { app: [...common, "app/**", "components/**", "src/**", "public/**", "package.json", "tsconfig.json"], ci: [...common, ".env.example", "CODEOWNERS"], extended: ["tests/**", "playwright/**", "docker/**", "infra/**"] }; + break; case "node-ts-service": - return { + paths = { app: [...common, "src/**", "tests/**", "package.json", "tsconfig.json"], ci: [...common, ".env.example", "CODEOWNERS"], extended: ["docker/**", "infra/**", "ops/**"] }; + break; case "python-service": - return { + paths = { app: [...common, "src/**", "tests/**", "pyproject.toml", ".python-version"], ci: [...common, ".env.example", "CODEOWNERS"], extended: ["docker/**", "infra/**", "ops/**"] }; + break; case "generic-empty": - return { + paths = { app: [...common, "README.md", "docs/**"], ci: [...common, ".env.example", "CODEOWNERS"], extended: ["infra/**", "ops/**"] }; + break; } + + return { + app: [...paths.app, ...manifest.ci.appPaths], + ci: [...paths.ci, ...manifest.ci.ciPaths], + extended: [...paths.extended, ...manifest.ci.extendedPaths] + }; } function setupSteps(manifest: BootstrapManifest): string { @@ -1785,7 +1821,7 @@ export function renderManagedFiles(manifest: BootstrapManifest): RenderedFile[] { path: "scripts/ci/run-release-verification.sh", reason: "Release verification entrypoint", - contents: releaseVerificationScript(), + contents: releaseVerificationScript(manifest), executable: true }, { diff --git a/src/manifest.ts b/src/manifest.ts index 97d138a..d2746c1 100644 --- a/src/manifest.ts +++ b/src/manifest.ts @@ -141,6 +141,19 @@ const dependabotSchema = z.object({ ecosystems: z.array(dependabotEcosystemSchema).optional() }); +const macosCheckSchema = z.object({ + enabled: z.boolean().optional(), + paths: z.array(z.string().min(1)).optional(), + runsOn: z.array(z.string().min(1)).optional(), + command: z.string().min(1).optional() +}); + +const customScriptsSchema = z.object({ + fast: z.string().min(1).optional(), + extended: z.string().min(1).optional(), + releaseVerification: z.string().min(1).optional() +}); + const manifestSchema = z.object({ version: z.literal(1).optional(), project: z.object({ @@ -199,6 +212,11 @@ const manifestSchema = z.object({ extendedChecks: z.array(z.string()).optional(), nightlyCron: z.string().optional(), additionalWorkflows: z.array(additionalWorkflowSchema).optional(), + appPaths: z.array(z.string().min(1)).optional(), + ciPaths: z.array(z.string().min(1)).optional(), + extendedPaths: z.array(z.string().min(1)).optional(), + macosCheck: macosCheckSchema.optional(), + customScripts: customScriptsSchema.optional(), dependabot: dependabotSchema.optional(), aiAttestation: z .object({ @@ -385,6 +403,31 @@ function normalizeDependabot( }; } +function normalizePaths(paths: string[] | undefined): string[] { + return (paths ?? []).map((entry) => entry.replace(/\\/g, "/")); +} + +function normalizeMacOSCheck( + check: z.input | undefined +): BootstrapManifest["ci"]["macosCheck"] { + return { + enabled: check?.enabled ?? false, + paths: normalizePaths(check?.paths), + runsOn: check?.runsOn ?? ["macos-14"], + command: check?.command ?? "xcodebuild -version" + }; +} + +function normalizeCustomScripts( + scripts: z.input | undefined +): BootstrapManifest["ci"]["customScripts"] { + return { + ...(scripts?.fast ? { fast: scripts.fast } : {}), + ...(scripts?.extended ? { extended: scripts.extended } : {}), + ...(scripts?.releaseVerification ? { releaseVerification: scripts.releaseVerification } : {}) + }; +} + export function normalizeManifest(raw: z.input): BootstrapManifest { const parsed = manifestSchema.parse(raw); const reviewers = (parsed.github?.reviewers ?? []).map((reviewer) => reviewer.replace(/^@/, "")); @@ -456,6 +499,11 @@ export function normalizeManifest(raw: z.input): Bootstra extendedChecks: parsed.ci?.extendedChecks ?? ["integration", "release-readiness"], nightlyCron: parsed.ci?.nightlyCron ?? "0 7 * * *", additionalWorkflows: normalizeAdditionalWorkflows(parsed.ci?.additionalWorkflows), + appPaths: normalizePaths(parsed.ci?.appPaths), + ciPaths: normalizePaths(parsed.ci?.ciPaths), + extendedPaths: normalizePaths(parsed.ci?.extendedPaths), + macosCheck: normalizeMacOSCheck(parsed.ci?.macosCheck), + customScripts: normalizeCustomScripts(parsed.ci?.customScripts), dependabot: normalizeDependabot(parsed.ci?.dependabot), aiAttestation: { enabled: parsed.ci?.aiAttestation?.enabled ?? false, diff --git a/src/types.ts b/src/types.ts index d7b5ad2..0c8d2b7 100644 --- a/src/types.ts +++ b/src/types.ts @@ -45,6 +45,19 @@ export interface DependabotConfig { ecosystems: DependabotEcosystemConfig[]; } +export interface MacOSCheckConfig { + enabled: boolean; + paths: string[]; + runsOn: string[]; + command: string; +} + +export interface CustomScriptsConfig { + fast?: string; + extended?: string; + releaseVerification?: string; +} + export interface OrganizationSecurityDefaults { dependabotAlerts: boolean; dependabotSecurityUpdates: boolean; @@ -112,6 +125,11 @@ export interface BootstrapManifest { extendedChecks: string[]; nightlyCron: string; additionalWorkflows: AdditionalWorkflowConfig[]; + appPaths: string[]; + ciPaths: string[]; + extendedPaths: string[]; + macosCheck: MacOSCheckConfig; + customScripts: CustomScriptsConfig; dependabot: DependabotConfig; aiAttestation: { enabled: boolean; diff --git a/tests/reusable-workflows.test.ts b/tests/reusable-workflows.test.ts index 80e0e70..18d4fdc 100644 --- a/tests/reusable-workflows.test.ts +++ b/tests/reusable-workflows.test.ts @@ -22,9 +22,35 @@ describe("reusable workflows", () => { expect(workflow.name).toBe("Reusable Release"); expect((workflow.on as any).workflow_call.inputs["runs-on"].default).toBe('["ubuntu-latest"]'); expect((workflow.on as any).workflow_call.inputs["verify-script"].default).toContain("run-release-verification"); + expect((workflow.on as any).workflow_call.inputs["version-script"].default).toContain("run-release-version"); + expect((workflow.on as any).workflow_call.inputs["build-script"].default).toContain("run-release-build"); + expect((workflow.on as any).workflow_call.inputs["release-notes-file"].default).toBe( + "dist/release/RELEASE_NOTES.md" + ); expect((workflow.on as any).workflow_call.inputs["tag-prefix"].default).toBe("v"); expect((workflow.on as any).workflow_call.inputs["update-major-tag"].default).toBe(true); - expect((workflow.jobs as any).release).toBeTruthy(); + const releaseJob = (workflow.jobs as any).release; + expect(releaseJob).toBeTruthy(); + const stepNames = releaseJob.steps.map((step: any) => step.name).filter(Boolean); + expect(stepNames).toEqual([ + "Derive release metadata", + "Verify release contract", + "Run release version hook", + "Build release artifacts", + "Publish release artifacts", + "Create GitHub release", + "Promote floating SemVer tags" + ]); + const deriveMetadata = releaseJob.steps.find((step: any) => step.name === "Derive release metadata"); + const promoteTags = releaseJob.steps.find((step: any) => step.name === "Promote floating SemVer tags"); + expect(deriveMetadata.run).toContain("semver_component='(0|[1-9][0-9]*)'"); + expect(promoteTags.run).toContain("semver_component='(0|[1-9][0-9]*)'"); + expect(deriveMetadata.run).toContain( + "^${escaped_prefix}${semver_component}\\.${semver_component}\\.${semver_component}$" + ); + expect(promoteTags.run).toContain( + "^${escaped_prefix}${semver_component}\\.${semver_component}\\.${semver_component}$" + ); }); it("defines the reusable AI attestation workflow contract", () => {