diff --git a/.github/ISSUE_TEMPLATE/release_train.yml b/.github/ISSUE_TEMPLATE/release_train.yml new file mode 100644 index 0000000..26cc33e --- /dev/null +++ b/.github/ISSUE_TEMPLATE/release_train.yml @@ -0,0 +1,63 @@ +name: Release Train +about: Governed release train for an OMT-Global repository +title: "Release: vX.Y.Z" +labels: + - release:train + - review:release +body: + - type: input + id: version + attributes: + label: Version + placeholder: v1.2.3 or v1.2.3-rc.1 + validations: + required: true + - type: dropdown + id: channel + attributes: + label: Channel + options: + - rc + - beta + - stable + - maintenance + validations: + required: true + - type: input + id: release_branch + attributes: + label: Release branch + placeholder: release/1.2 + - type: input + id: target_sha + attributes: + label: Target SHA + placeholder: Full commit SHA for the release candidate + - type: textarea + id: scope + attributes: + label: Scope + description: User-facing changes, fixes, risks, exclusions. + validations: + required: true + - type: textarea + id: gates + attributes: + label: Gates + value: | + - [ ] Release branch created + - [ ] Scope locked + - [ ] Changelog/release notes prepared + - [ ] Version surfaces updated + - [ ] Preflight passed + - [ ] preflight_run_id recorded: + - [ ] Full validation passed + - [ ] validation_run_id recorded: + - [ ] Exact tag created + - [ ] Publish approval granted + - [ ] Artifacts published + - [ ] GitHub Release created or updated + - [ ] Release evidence uploaded + - [ ] Postpublish verification passed + - [ ] Floating tags/channels promoted if applicable + - [ ] Release issue closed diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..e75d986 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,27 @@ +# Generated by OMT Bootstrap. Keep dependency policy in project.bootstrap.yaml. +# Dependabot alerts + security updates are managed through GitHub security settings; +# this file governs routine scheduled version update PRs. +version: 2 +updates: + - package-ecosystem: "npm" + directory: "/" + schedule: + interval: "weekly" + groups: + npm-minor-patch: + update-types: + - "minor" + - "patch" + ignore: + - dependency-name: "*" + update-types: + - "version-update:semver-major" + + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + ignore: + - dependency-name: "*" + update-types: + - "version-update:semver-major" diff --git a/.github/workflows/ai-attestation-reusable.yml b/.github/workflows/ai-attestation-reusable.yml index 4086bb4..6970ae6 100644 --- a/.github/workflows/ai-attestation-reusable.yml +++ b/.github/workflows/ai-attestation-reusable.yml @@ -30,11 +30,7 @@ permissions: jobs: attest: - runs-on: - - self-hosted - - linux - - shell-only - - public + runs-on: ubuntu-latest outputs: sha: ${{ steps.meta.outputs.sha }} steps: @@ -42,30 +38,6 @@ jobs: with: fetch-depth: 1 - - name: Ensure envsubst is available - shell: bash - run: | - set -euo pipefail - if command -v envsubst >/dev/null 2>&1; then - exit 0 - fi - - if ! command -v apt-get >/dev/null 2>&1; then - echo "envsubst is required by cosign-installer, but apt-get is unavailable." >&2 - exit 1 - fi - - if [[ "$(id -u)" == "0" ]]; then - apt-get update - apt-get install -y --no-install-recommends gettext-base - elif command -v sudo >/dev/null 2>&1; then - sudo apt-get update - sudo apt-get install -y --no-install-recommends gettext-base - else - echo "envsubst is missing and this runner cannot install gettext-base." >&2 - exit 1 - fi - - name: Install cosign uses: sigstore/cosign-installer@v4.1.1 @@ -117,37 +89,9 @@ jobs: retention-days: ${{ inputs.retention_days }} verify: - runs-on: - - self-hosted - - linux - - shell-only - - public + runs-on: ubuntu-latest needs: attest steps: - - name: Ensure envsubst is available - shell: bash - run: | - set -euo pipefail - if command -v envsubst >/dev/null 2>&1; then - exit 0 - fi - - if ! command -v apt-get >/dev/null 2>&1; then - echo "envsubst is required by cosign-installer, but apt-get is unavailable." >&2 - exit 1 - fi - - if [[ "$(id -u)" == "0" ]]; then - apt-get update - apt-get install -y --no-install-recommends gettext-base - elif command -v sudo >/dev/null 2>&1; then - sudo apt-get update - sudo apt-get install -y --no-install-recommends gettext-base - else - echo "envsubst is missing and this runner cannot install gettext-base." >&2 - exit 1 - fi - - name: Install cosign uses: sigstore/cosign-installer@v4.1.1 diff --git a/.github/workflows/full-release-validation-reusable.yml b/.github/workflows/full-release-validation-reusable.yml new file mode 100644 index 0000000..c83d79d --- /dev/null +++ b/.github/workflows/full-release-validation-reusable.yml @@ -0,0 +1,56 @@ +name: Reusable Full Release Validation + +on: + workflow_call: + inputs: + target_ref: { required: true, type: string } + release_profile: { required: true, type: string } + runs_on: { required: false, type: string, default: '["ubuntu-latest"]' } + validate_script: { required: false, type: string, default: scripts/release/validate.sh } + artifact_dir: { required: false, type: string, default: dist/release } + evidence_artifact_name: { required: false, type: string, default: release-evidence } + evidence_retention_days: { required: false, type: number, default: 365 } + +permissions: + contents: read + actions: read + +jobs: + validate: + runs-on: ${{ fromJSON(inputs.runs_on || '["ubuntu-latest"]') }} + defaults: + run: + shell: bash + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ inputs.target_ref }} + fetch-depth: 0 + - name: Run release validation + env: + TARGET_REF: ${{ inputs.target_ref }} + RELEASE_PROFILE: ${{ inputs.release_profile }} + VALIDATE_SCRIPT: ${{ inputs.validate_script }} + ARTIFACT_DIR: ${{ inputs.artifact_dir }} + run: | + set -euo pipefail + mkdir -p "$ARTIFACT_DIR" + validate_status=skipped + standard_status=skipped + if [[ -x "$VALIDATE_SCRIPT" ]]; then + "$VALIDATE_SCRIPT" + validate_status=passed + fi + if [[ -f package.json ]] && node -e "const p=require('./package.json'); process.exit(p.scripts?.check ? 0 : 1)" >/dev/null 2>&1; then + npm run check + standard_status=passed + fi + target_sha="$(git rev-parse HEAD)" + cat >"$ARTIFACT_DIR/validation-evidence.json" </dev/null + asset_count="$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json assets --jq '.assets | length')" + [[ "$asset_count" -ge 1 ]] || { echo "Release has no assets." >&2; exit 1; } + if [[ -x "$POSTPUBLISH_SCRIPT" ]]; then + "$POSTPUBLISH_SCRIPT" "$TAG" + else + echo "Postpublish verification hook is missing or not executable: $POSTPUBLISH_SCRIPT" >&2 + exit 1 + fi + printf '{"schema_version":1,"repo":"%s","tag":"%s","channel":"%s","release_issue":"%s","postpublish_run_id":"%s","release_assets":%s}\n' "$GITHUB_REPOSITORY" "$TAG" "$CHANNEL" "$RELEASE_ISSUE" "$GITHUB_RUN_ID" "$asset_count" >"$ARTIFACT_DIR/postpublish-evidence.json" + - uses: actions/upload-artifact@v4 + with: + name: ${{ inputs.evidence_artifact_name }}-postpublish + path: ${{ inputs.artifact_dir }}/postpublish-evidence.json diff --git a/.github/workflows/release-postpublish.yml b/.github/workflows/release-postpublish.yml new file mode 100644 index 0000000..32ae018 --- /dev/null +++ b/.github/workflows/release-postpublish.yml @@ -0,0 +1,30 @@ +name: Release Postpublish + +on: + workflow_dispatch: + inputs: + tag: + description: Exact release tag to verify + required: true + type: string + channel: + description: Release channel + required: true + default: stable + type: choice + options: [rc, beta, stable, maintenance] + release_issue: + description: Release issue number + required: false + type: string + +jobs: + postpublish: + uses: OMT-Global/bootstrap/.github/workflows/release-postpublish-reusable.yml@refs/heads/main + with: + tag: ${{ inputs.tag }} + channel: ${{ inputs.channel }} + release_issue: ${{ inputs.release_issue }} + postpublish_script: scripts/release/postpublish.sh + artifact_dir: dist/release + evidence_artifact_name: release-evidence diff --git a/.github/workflows/release-preflight-reusable.yml b/.github/workflows/release-preflight-reusable.yml new file mode 100644 index 0000000..303ba80 --- /dev/null +++ b/.github/workflows/release-preflight-reusable.yml @@ -0,0 +1,91 @@ +name: Reusable Release Preflight + +on: + workflow_call: + inputs: + version: { required: true, type: string } + channel: { required: true, type: string } + target_ref: { required: true, type: string } + runs_on: { required: false, type: string, default: '["ubuntu-latest"]' } + artifact_dir: { required: false, type: string, default: dist/release } + release_notes_file: { required: false, type: string, default: dist/release/RELEASE_NOTES.md } + release_issue: { required: false, type: string, default: "" } + prep_script: { required: false, type: string, default: scripts/release/prep.sh } + preflight_script: { required: false, type: string, default: scripts/release/preflight.sh } + build_script: { required: false, type: string, default: scripts/release/build.sh } + tag_prefix: { required: false, type: string, default: v } + default_branch: { required: false, type: string, default: main } + evidence_artifact_name: { required: false, type: string, default: release-evidence } + evidence_retention_days: { required: false, type: number, default: 365 } + +permissions: + contents: read + actions: read + id-token: write + +jobs: + preflight: + runs-on: ${{ fromJSON(inputs.runs_on || '["ubuntu-latest"]') }} + defaults: + run: + shell: bash + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ inputs.target_ref }} + fetch-depth: 0 + + - name: Preflight release candidate + env: + VERSION: ${{ inputs.version }} + CHANNEL: ${{ inputs.channel }} + TARGET_REF: ${{ inputs.target_ref }} + RELEASE_ISSUE: ${{ inputs.release_issue }} + ARTIFACT_DIR: ${{ inputs.artifact_dir }} + RELEASE_NOTES_FILE: ${{ inputs.release_notes_file }} + PREP_SCRIPT: ${{ inputs.prep_script }} + PREFLIGHT_SCRIPT: ${{ inputs.preflight_script }} + BUILD_SCRIPT: ${{ inputs.build_script }} + TAG_PREFIX: ${{ inputs.tag_prefix }} + DEFAULT_BRANCH: ${{ inputs.default_branch }} + run: | + set -euo pipefail + semver='(0|[1-9][0-9]*)' + escaped_prefix="$(printf '%s' "$TAG_PREFIX" | sed -E 's/[][(){}.^$+*?|\]/\&/g')" + [[ "$VERSION" =~ ^${escaped_prefix}${semver}\.${semver}\.${semver}(-(rc|beta)\.[0-9]+)?$ ]] || { echo "Invalid release version: $VERSION" >&2; exit 1; } + target_sha="$(git rev-parse HEAD)" + prep_status=skipped; preflight_status=skipped; build_status=skipped + if [[ -x "$PREP_SCRIPT" ]]; then + "$PREP_SCRIPT" + prep_status=passed + fi + if [[ -x "$PREFLIGHT_SCRIPT" ]]; then + "$PREFLIGHT_SCRIPT" + preflight_status=passed + fi + if [[ -x "$BUILD_SCRIPT" ]]; then + "$BUILD_SCRIPT" + build_status=passed + fi + mkdir -p "$ARTIFACT_DIR" "$(dirname "$RELEASE_NOTES_FILE")" + [[ -f "$RELEASE_NOTES_FILE" ]] || printf '# Release Notes\n\nCandidate: %s\n' "$VERSION" >"$RELEASE_NOTES_FILE" + : >"$ARTIFACT_DIR/SHA256SUMS" + find "$ARTIFACT_DIR" -maxdepth 1 -type f ! -name SHA256SUMS ! -name release-evidence.json -print0 | sort -z | xargs -0 shasum -a 256 >>"$ARTIFACT_DIR/SHA256SUMS" + cat >"$ARTIFACT_DIR/release-evidence.json" <&2; exit 1; } + tag_sha="$(git rev-parse "$TAG^{commit}")" + if [[ "$REQUIRE_SIGNED_TAG" == "true" ]]; then + git tag -v "$TAG" >/dev/null + fi + rm -rf "$ARTIFACT_DIR"; mkdir -p "$ARTIFACT_DIR" + PREFLIGHT_ARTIFACT_DIR="$(mktemp -d "${RUNNER_TEMP:-/tmp}/release-preflight.XXXXXX")" + gh run download "$PREFLIGHT_RUN_ID" --repo "$GITHUB_REPOSITORY" --name release-package --dir "$PREFLIGHT_ARTIFACT_DIR" + [[ -f "$PREFLIGHT_ARTIFACT_DIR/release-evidence.json" ]] || { echo "Missing preflight release-evidence.json." >&2; exit 1; } + evidence_target_sha="$(node -e 'const fs=require("fs"); const p=JSON.parse(fs.readFileSync(process.argv[1],"utf8")); if (!p.target_sha) process.exit(2); process.stdout.write(p.target_sha)' "$PREFLIGHT_ARTIFACT_DIR/release-evidence.json")" + [[ "$evidence_target_sha" == "$tag_sha" ]] || { echo "Preflight evidence target SHA does not match tag SHA." >&2; exit 1; } + evidence_preflight_run_id="$(node -e 'const fs=require("fs"); const p=JSON.parse(fs.readFileSync(process.argv[1],"utf8")); if (!p.preflight_run_id) process.exit(2); process.stdout.write(p.preflight_run_id)' "$PREFLIGHT_ARTIFACT_DIR/release-evidence.json")" + [[ "$evidence_preflight_run_id" == "$PREFLIGHT_RUN_ID" ]] || { echo "Preflight evidence run ID does not match the requested preflight run." >&2; exit 1; } + VALIDATION_ARTIFACT_DIR="$(mktemp -d "${RUNNER_TEMP:-/tmp}/release-validation.XXXXXX")" + gh run download "$VALIDATION_RUN_ID" --repo "$GITHUB_REPOSITORY" --name release-evidence-validation --dir "$VALIDATION_ARTIFACT_DIR" + [[ -f "$VALIDATION_ARTIFACT_DIR/validation-evidence.json" ]] || { echo "Missing validation validation-evidence.json." >&2; exit 1; } + validation_target_sha="$(node -e 'const fs=require("fs"); const p=JSON.parse(fs.readFileSync(process.argv[1],"utf8")); if (!p.target_sha) process.exit(2); process.stdout.write(p.target_sha)' "$VALIDATION_ARTIFACT_DIR/validation-evidence.json")" + [[ "$validation_target_sha" == "$tag_sha" ]] || { echo "Validation evidence target SHA does not match tag SHA." >&2; exit 1; } + validation_run_id="$(node -e 'const fs=require("fs"); const p=JSON.parse(fs.readFileSync(process.argv[1],"utf8")); if (!p.validation_run_id) process.exit(2); process.stdout.write(p.validation_run_id)' "$VALIDATION_ARTIFACT_DIR/validation-evidence.json")" + [[ "$validation_run_id" == "$VALIDATION_RUN_ID" ]] || { echo "Validation evidence run ID does not match the requested validation run." >&2; exit 1; } + validation_repo="$(node -e 'const fs=require("fs"); const p=JSON.parse(fs.readFileSync(process.argv[1],"utf8")); if (!p.repo) process.exit(2); process.stdout.write(p.repo)' "$VALIDATION_ARTIFACT_DIR/validation-evidence.json")" + [[ "$validation_repo" == "$GITHUB_REPOSITORY" ]] || { echo "Validation evidence repo does not match the current repository." >&2; exit 1; } + gh run view "$VALIDATION_RUN_ID" --repo "$GITHUB_REPOSITORY" --json conclusion --jq '.conclusion' | grep -qx success + if [[ -x "$PUBLISH_SCRIPT" ]]; then + "$PUBLISH_SCRIPT" + fi + if [[ "$CREATE_GITHUB_RELEASE" == "true" ]]; then + [[ -f "$PREFLIGHT_ARTIFACT_DIR/SHA256SUMS" ]] || { echo "Missing preflight SHA256SUMS manifest." >&2; exit 1; } + RELEASE_ASSET_DIR="$(mktemp -d "${RUNNER_TEMP:-/tmp}/release-assets.XXXXXX")" + [[ "$RELEASE_ASSET_DIR" != "$PREFLIGHT_ARTIFACT_DIR" && "$RELEASE_ASSET_DIR" != "$VALIDATION_ARTIFACT_DIR" ]] || { echo "Release asset staging directory must be isolated from evidence download directories." >&2; exit 1; } + release_assets=() + while read -r asset_sha asset_path; do + [[ -n "${asset_sha:-}" && -n "${asset_path:-}" ]] || continue + [[ "$asset_path" != *"release-evidence.json" && "$asset_path" != *"validation-evidence.json" ]] || continue + [[ -f "$PREFLIGHT_ARTIFACT_DIR/$asset_path" ]] || { echo "Missing preflight release asset: $asset_path" >&2; exit 1; } + asset_name="$(basename "$asset_path")" + cp -p -- "$PREFLIGHT_ARTIFACT_DIR/$asset_path" "$RELEASE_ASSET_DIR/$asset_name" + release_assets+=("$RELEASE_ASSET_DIR/$asset_name") + done < "$PREFLIGHT_ARTIFACT_DIR/SHA256SUMS" + [[ ${#release_assets[@]} -gt 0 ]] || { echo "No release assets were staged for upload." >&2; exit 1; } + release_args=() + [[ "$TAG" == *"-rc."* || "$TAG" == *"-beta."* ]] && release_args+=(--prerelease --latest=false) + gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1 \ + && gh release upload "$TAG" "${release_assets[@]}" --repo "$GITHUB_REPOSITORY" --clobber \ + || gh release create "$TAG" "${release_assets[@]}" --repo "$GITHUB_REPOSITORY" --notes-file "$RELEASE_NOTES_FILE" "${release_args[@]}" + fi + if [[ "$TAG" != *"-rc."* && "$TAG" != *"-beta."* ]]; then + version="${TAG#"$TAG_PREFIX"}"; IFS=. read -r major minor patch <<<"$version" + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + [[ "$UPDATE_MINOR_TAG" == "true" ]] && git tag -f "${TAG_PREFIX}${major}.${minor}" "$tag_sha" && git push -f origin "refs/tags/${TAG_PREFIX}${major}.${minor}" + [[ "$UPDATE_MAJOR_TAG" == "true" ]] && git tag -f "${TAG_PREFIX}${major}" "$tag_sha" && git push -f origin "refs/tags/${TAG_PREFIX}${major}" + fi + if [[ -x "$POSTPUBLISH_SCRIPT" ]]; then + "$POSTPUBLISH_SCRIPT" "$TAG" + elif [[ "$REQUIRE_POSTPUBLISH_VERIFICATION" == "true" ]]; then + echo "Postpublish verification script is required but missing or not executable." >&2 + exit 1 + fi + printf '{"schema_version":1,"repo":"%s","tag":"%s","tag_sha":"%s","publish_run_id":"%s"}\n' "$GITHUB_REPOSITORY" "$TAG" "$tag_sha" "$GITHUB_RUN_ID" >"$ARTIFACT_DIR/postpublish-evidence.json" + - uses: actions/upload-artifact@v4 + with: + name: ${{ inputs.evidence_artifact_name }}-publish + path: ${{ inputs.artifact_dir }}/postpublish-evidence.json diff --git a/.github/workflows/release-publish.yml b/.github/workflows/release-publish.yml new file mode 100644 index 0000000..5b713ca --- /dev/null +++ b/.github/workflows/release-publish.yml @@ -0,0 +1,52 @@ +name: Release Publish + +on: + workflow_dispatch: + inputs: + tag: + description: Exact release tag, e.g. v1.2.3 + required: true + type: string + channel: + description: Release channel + required: true + default: stable + type: choice + options: [rc, beta, stable, maintenance] + preflight_run_id: + description: Successful Release Preflight run ID + required: true + type: string + validation_run_id: + description: Successful Full Release Validation run ID + required: true + type: string + release_issue: + description: Release issue number + required: false + type: string + +jobs: + publish: + uses: OMT-Global/bootstrap/.github/workflows/release-publish-reusable.yml@refs/heads/main + with: + tag: ${{ inputs.tag }} + channel: ${{ inputs.channel }} + preflight_run_id: ${{ inputs.preflight_run_id }} + validation_run_id: ${{ inputs.validation_run_id }} + release_issue: ${{ inputs.release_issue }} + publish_script: scripts/release/publish.sh + postpublish_script: scripts/release/postpublish.sh + artifact_dir: dist/release + release_notes_file: dist/release/RELEASE_NOTES.md + create_github_release: true + update_major_tag: true + update_minor_tag: true + tag_prefix: 'v' + default_branch: main + require_release_issue: true + require_signed_tag: false + require_postpublish_verification: true + evidence_artifact_name: release-evidence + publish_environment: release-publish + secrets: inherit diff --git a/.github/workflows/release-tag.yml b/.github/workflows/release-tag.yml deleted file mode 100644 index 65f79b5..0000000 --- a/.github/workflows/release-tag.yml +++ /dev/null @@ -1,27 +0,0 @@ -name: Release - -on: - push: - tags: - - 'v*.*.*' - -permissions: - contents: write - id-token: write - packages: write - -jobs: - release: - uses: OMT-Global/bootstrap/.github/workflows/release.yml@refs/heads/main - with: - runs-on: '["self-hosted","linux","shell-only","public"]' - verify-script: scripts/ci/run-release-verification.sh - version-script: scripts/ci/run-release-version.sh - build-script: scripts/ci/run-release-build.sh - publish-script: scripts/ci/run-release-publish.sh - release-notes-file: dist/release/RELEASE_NOTES.md - artifact-dir: dist/release - create-github-release: true - tag-prefix: 'v' - update-major-tag: true - update-minor-tag: true diff --git a/docs/bootstrap/release-evidence-schema.md b/docs/bootstrap/release-evidence-schema.md new file mode 100644 index 0000000..abeb841 --- /dev/null +++ b/docs/bootstrap/release-evidence-schema.md @@ -0,0 +1,38 @@ +# Release Evidence Schema + +Governed releases emit machine-readable evidence into `dist/release/`. + +Minimum release evidence: + +```json +{ + "schema_version": 1, + "repo": "OMT-Global/example", + "version": "v1.2.3", + "channel": "stable", + "target_ref": "release/1.2", + "target_sha": "full_sha", + "release_issue": "123", + "preflight_run_id": "123456789", + "validation_run_id": "123456790", + "publish_run_id": "123456791", + "artifacts": [{ "name": "example-v1.2.3.tar.gz", "sha256": "..." }], + "checks": { + "prep": "passed", + "preflight": "passed", + "build": "passed", + "full_validation": "passed", + "publish": "passed", + "postpublish": "passed" + } +} +``` + +Expected files: + +- `dist/release/release-evidence.json` +- `dist/release/postpublish-evidence.json` +- `dist/release/SHA256SUMS` +- `dist/release/RELEASE_NOTES.md` + +Evidence links the release issue, target SHA, workflow run IDs, artifact checksums, release notes, and postpublish status so a release can be audited without relying on local operator state. diff --git a/docs/bootstrap/release-train-contract.md b/docs/bootstrap/release-train-contract.md new file mode 100644 index 0000000..133b241 --- /dev/null +++ b/docs/bootstrap/release-train-contract.md @@ -0,0 +1,40 @@ +# Governed Release Train Contract + +Bootstrap supports four release maturity levels: + +| Level | Name | Behavior | +| ---: | --- | --- | +| 0 | `none` | No managed release files are generated. | +| 1 | `simple` | Existing tag-triggered SemVer release workflow remains available. | +| 2 | `governed` | Adds release preflight, full validation, publish orchestration, postpublish verification, and release evidence. | +| 3 | `regulated` | Uses governed release flow plus stricter gates where supported, including signed tag verification when required. | + +Backwards compatibility is intentional: `release.enabled: true` without `release.maturity` is treated as `simple`, and `release.enabled: false` is treated as `none`. + +## Configure + +```yaml +release: + enabled: true + maturity: governed + reusableWorkflowRepo: OMT-Global/bootstrap + reusableWorkflowRef: refs/heads/main +``` + +Governed repos receive thin caller workflows for preflight, validation, publish, and postpublish verification. Package-specific behavior belongs in hook scripts under `scripts/release/`. + +## Manual Release Flow + +1. Open a release train issue. +2. Create or update the release branch. +3. Run `Release Preflight` and record `preflight_run_id`. +4. Run `Full Release Validation` and record `validation_run_id`. +5. Create the exact tag after validation evidence exists. +6. Run `Release Publish` with the tag, `preflight_run_id`, and `validation_run_id`. +7. Verify postpublish evidence and close or supersede the release issue. + +Publish must consume the artifact bundle from the preflight run. If the preflight artifact cannot be downloaded, or if evidence does not match the tag SHA, publish fails rather than rebuilding. + +## Secrets + +Generated hooks are safe no-ops by default. Production credentials belong in GitHub environments, secrets, packages, or OIDC configuration, not in manifests or generated scripts. diff --git a/docs/bootstrap/versioning.md b/docs/bootstrap/versioning.md index 93038b6..557783c 100644 --- a/docs/bootstrap/versioning.md +++ b/docs/bootstrap/versioning.md @@ -37,7 +37,7 @@ Consumers should prefer `v1` for the default compatibility channel, `v1.2` when - The default release artifact directory is `dist/release/`. - `scripts/ci/run-release-build.sh` is where repo-specific build steps populate that directory; with no artifacts it prints an explicit no-op message instead of failing silently. - Checksum generation is `sha256`; when set to `sha256` a `SHA256SUMS` file is written alongside the artifacts. -- The reusable workflow uploads every file in the artifact directory to the GitHub Release. +- The reusable workflow uploads the release bundle listed in `SHA256SUMS` plus the release notes, while excluding validation evidence artifacts. - SBOM/provenance is `optional` and is designed into the manifest for repos that opt in. ## Release Notes diff --git a/docs/release-train.md b/docs/release-train.md new file mode 100644 index 0000000..6d7b697 --- /dev/null +++ b/docs/release-train.md @@ -0,0 +1,30 @@ +# Governed Release Train + +This repository uses release maturity level `governed`. + +## Manual Flow + +1. Open a release train issue with `.github/ISSUE_TEMPLATE/release_train.yml`. +2. Create or update the `release/{major}.{minor}` release branch. +3. Run `Release Preflight` with the candidate version, channel, target ref, and release issue. +4. Copy the successful preflight run ID into the release issue. +5. Run `Full Release Validation` against the same target ref. +6. Copy the successful validation run ID into the release issue. +7. Create the exact release tag only after validation evidence exists. +8. Run `Release Publish` with the tag, preflight run ID, validation run ID, channel, and release issue. +9. Run or review `Release Postpublish`, then close or supersede the release issue. + +Publish must consume the artifact bundle proven by preflight. If the preflight artifact cannot be downloaded or its recorded target SHA differs from the tag SHA, publish must fail instead of rebuilding. + +## Customization + +Repo-specific behavior belongs in these hook scripts: + +- `scripts/release/prep.sh` +- `scripts/release/preflight.sh` +- `scripts/release/validate.sh` +- `scripts/release/build.sh` +- `scripts/release/publish.sh` +- `scripts/release/postpublish.sh` + +The generated defaults do not require secrets and do not publish external packages. diff --git a/package-lock.json b/package-lock.json index f661894..c3c65a1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,11 +1,11 @@ { - "name": "new-project-setup", + "name": "bootstrap", "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "new-project-setup", + "name": "bootstrap", "version": "0.1.0", "dependencies": { "commander": "^14.0.2", @@ -13,6 +13,7 @@ "zod": "^4.1.12" }, "bin": { + "bootstrap": "dist/cli.js", "project-bootstrap": "dist/cli.js" }, "devDependencies": { @@ -855,7 +856,6 @@ "integrity": "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -1254,7 +1254,6 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -1457,7 +1456,6 @@ "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" @@ -1686,7 +1684,6 @@ "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", "license": "ISC", - "peer": true, "bin": { "yaml": "bin.mjs" }, diff --git a/project.bootstrap.yaml b/project.bootstrap.yaml index 144fa7d..59f13c5 100644 --- a/project.bootstrap.yaml +++ b/project.bootstrap.yaml @@ -181,6 +181,7 @@ ci: reusableWorkflowRef: refs/heads/main release: enabled: true + maturity: governed tagPrefix: v createGitHubRelease: true updateMajorTag: true diff --git a/scripts/ci/run-release-build.sh b/scripts/ci/run-release-build.sh index ef98f0a..c423e76 100755 --- a/scripts/ci/run-release-build.sh +++ b/scripts/ci/run-release-build.sh @@ -7,7 +7,7 @@ mkdir -p "${artifact_dir}" # Add repo-specific build steps above this line to populate ${artifact_dir} # with downloadable release assets before checksums are generated. -mapfile -t artifacts < <(find "${artifact_dir}" -maxdepth 1 -type f ! -name SHA256SUMS | sort) +mapfile -t artifacts < <(find "${artifact_dir}" -maxdepth 1 -type f ! -name SHA256SUMS ! -name release-evidence.json ! -name validation-evidence.json | sort) if [[ ${#artifacts[@]} -eq 0 ]]; then echo "No release artifacts were produced in ${artifact_dir}." echo "This repo ships no downloadable assets; add build steps to scripts/ci/run-release-build.sh when it does." diff --git a/scripts/release/build.sh b/scripts/release/build.sh new file mode 100755 index 0000000..0255e91 --- /dev/null +++ b/scripts/release/build.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +set -euo pipefail + +artifact_dir="dist/release" +mkdir -p "${artifact_dir}" + +if [[ ! -f "${artifact_dir}/artifact-manifest.json" ]]; then + cat >"${artifact_dir}/artifact-manifest.json" <"${artifact_dir}/RELEASE_NOTES.md" +fi + +echo "Prepared release artifact directory ${artifact_dir}." \ No newline at end of file diff --git a/scripts/release/postpublish.sh b/scripts/release/postpublish.sh new file mode 100755 index 0000000..9cb7401 --- /dev/null +++ b/scripts/release/postpublish.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +set -euo pipefail + +tag="${1:-${GITHUB_REF_NAME:-}}" +if [[ -n "${tag}" && -n "${GITHUB_REPOSITORY:-}" && -n "${GH_TOKEN:-}" ]]; then + gh release view "${tag}" --repo "${GITHUB_REPOSITORY}" >/dev/null + echo "GitHub Release ${tag} exists." +else + echo "No GitHub release lookup context available. Skipping postpublish remote check." +fi \ No newline at end of file diff --git a/scripts/release/preflight.sh b/scripts/release/preflight.sh new file mode 100755 index 0000000..0cf204c --- /dev/null +++ b/scripts/release/preflight.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ -x scripts/ci/run-fast-checks.sh ]]; then + bash scripts/ci/run-fast-checks.sh +else + echo "No fast-check script found. Skipping release preflight checks." +fi diff --git a/scripts/release/prep.sh b/scripts/release/prep.sh new file mode 100755 index 0000000..26fcec8 --- /dev/null +++ b/scripts/release/prep.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +set -euo pipefail + +echo "No repo-specific release prep step is configured." diff --git a/scripts/release/publish.sh b/scripts/release/publish.sh new file mode 100755 index 0000000..af0ee79 --- /dev/null +++ b/scripts/release/publish.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -euo pipefail + +echo "No external package publish step is configured." +echo "The reusable publish workflow creates or updates the GitHub Release from the preflight artifact." diff --git a/scripts/release/validate.sh b/scripts/release/validate.sh new file mode 100755 index 0000000..0d76111 --- /dev/null +++ b/scripts/release/validate.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ -x scripts/ci/run-extended-validation.sh ]]; then + bash scripts/ci/run-extended-validation.sh +elif [[ -x scripts/ci/run-fast-checks.sh ]]; then + bash scripts/ci/run-fast-checks.sh +else + echo "No validation script found. Skipping release validation checks." +fi diff --git a/src/archetypes.ts b/src/archetypes.ts index 0339c9b..5d1c1f8 100644 --- a/src/archetypes.ts +++ b/src/archetypes.ts @@ -1418,6 +1418,800 @@ function releaseCallerWorkflow(manifest: BootstrapManifest): string { `; } +function governedReleaseWorkflowRef(manifest: BootstrapManifest, workflow: string): string { + return `${manifest.release.reusableWorkflowRepo}/.github/workflows/${workflow}@${manifest.release.reusableWorkflowRef}`; +} + +function releasePreflightCallerWorkflow(manifest: BootstrapManifest): string { + return dedent` + name: Release Preflight + + on: + workflow_dispatch: + inputs: + version: + description: Release version, e.g. ${manifest.release.tagPrefix}1.2.3-rc.1 + required: true + type: string + channel: + description: Release channel + required: true + default: rc + type: choice + options: [rc, beta, stable, maintenance] + target_ref: + description: Branch, tag, or full SHA to preflight + required: true + type: string + release_issue: + description: Release issue number + required: false + type: string + + jobs: + preflight: + uses: ${governedReleaseWorkflowRef(manifest, "release-preflight-reusable.yml")} + with: + version: \${{ inputs.version }} + channel: \${{ inputs.channel }} + target_ref: \${{ inputs.target_ref }} + release_issue: \${{ inputs.release_issue }} + prep_script: scripts/release/prep.sh + preflight_script: scripts/release/preflight.sh + build_script: scripts/release/build.sh + artifact_dir: ${manifest.release.artifacts.directory} + release_notes_file: ${manifest.release.artifacts.directory}/RELEASE_NOTES.md + tag_prefix: '${manifest.release.tagPrefix}' + default_branch: ${manifest.project.defaultBranch} + evidence_artifact_name: release-evidence + evidence_retention_days: 365 + `; +} + +function fullReleaseValidationCallerWorkflow(manifest: BootstrapManifest): string { + return dedent` + name: Full Release Validation + + on: + workflow_dispatch: + inputs: + target_ref: + description: Branch, tag, or full SHA to validate + required: true + type: string + release_profile: + description: Validation depth + required: true + default: standard + type: choice + options: [smoke, standard, full] + + jobs: + validate: + uses: ${governedReleaseWorkflowRef(manifest, "full-release-validation-reusable.yml")} + with: + target_ref: \${{ inputs.target_ref }} + release_profile: \${{ inputs.release_profile }} + validate_script: scripts/release/validate.sh + artifact_dir: ${manifest.release.artifacts.directory} + evidence_artifact_name: release-evidence + evidence_retention_days: 365 + `; +} + +function releasePublishCallerWorkflow(manifest: BootstrapManifest): string { + return dedent` + name: Release Publish + + on: + workflow_dispatch: + inputs: + tag: + description: Exact release tag, e.g. ${manifest.release.tagPrefix}1.2.3 + required: true + type: string + channel: + description: Release channel + required: true + default: stable + type: choice + options: [rc, beta, stable, maintenance] + preflight_run_id: + description: Successful Release Preflight run ID + required: true + type: string + validation_run_id: + description: Successful Full Release Validation run ID + required: true + type: string + release_issue: + description: Release issue number + required: false + type: string + + jobs: + publish: + uses: ${governedReleaseWorkflowRef(manifest, "release-publish-reusable.yml")} + with: + tag: \${{ inputs.tag }} + channel: \${{ inputs.channel }} + preflight_run_id: \${{ inputs.preflight_run_id }} + validation_run_id: \${{ inputs.validation_run_id }} + release_issue: \${{ inputs.release_issue }} + publish_script: scripts/release/publish.sh + postpublish_script: scripts/release/postpublish.sh + artifact_dir: ${manifest.release.artifacts.directory} + release_notes_file: ${manifest.release.artifacts.directory}/RELEASE_NOTES.md + create_github_release: ${manifest.release.createGitHubRelease ? "true" : "false"} + update_major_tag: ${manifest.release.updateMajorTag ? "true" : "false"} + update_minor_tag: ${manifest.release.updateMinorTag ? "true" : "false"} + tag_prefix: '${manifest.release.tagPrefix}' + default_branch: ${manifest.project.defaultBranch} + require_release_issue: true + require_signed_tag: ${manifest.release.maturity === "regulated" ? "true" : "false"} + require_postpublish_verification: true + evidence_artifact_name: release-evidence + publish_environment: release-publish + secrets: inherit + `; +} + +function releasePostpublishCallerWorkflow(manifest: BootstrapManifest): string { + return dedent` + name: Release Postpublish + + on: + workflow_dispatch: + inputs: + tag: + description: Exact release tag to verify + required: true + type: string + channel: + description: Release channel + required: true + default: stable + type: choice + options: [rc, beta, stable, maintenance] + release_issue: + description: Release issue number + required: false + type: string + + jobs: + postpublish: + uses: ${governedReleaseWorkflowRef(manifest, "release-postpublish-reusable.yml")} + with: + tag: \${{ inputs.tag }} + channel: \${{ inputs.channel }} + release_issue: \${{ inputs.release_issue }} + postpublish_script: scripts/release/postpublish.sh + artifact_dir: ${manifest.release.artifacts.directory} + evidence_artifact_name: release-evidence + `; +} + +function releasePreflightReusableWorkflow(): string { + return dedent` + name: Reusable Release Preflight + + on: + workflow_call: + inputs: + version: { required: true, type: string } + channel: { required: true, type: string } + target_ref: { required: true, type: string } + runs_on: { required: false, type: string, default: '["ubuntu-latest"]' } + artifact_dir: { required: false, type: string, default: dist/release } + release_notes_file: { required: false, type: string, default: dist/release/RELEASE_NOTES.md } + release_issue: { required: false, type: string, default: "" } + prep_script: { required: false, type: string, default: scripts/release/prep.sh } + preflight_script: { required: false, type: string, default: scripts/release/preflight.sh } + build_script: { required: false, type: string, default: scripts/release/build.sh } + tag_prefix: { required: false, type: string, default: v } + default_branch: { required: false, type: string, default: main } + evidence_artifact_name: { required: false, type: string, default: release-evidence } + evidence_retention_days: { required: false, type: number, default: 365 } + + permissions: + contents: read + actions: read + id-token: write + + jobs: + preflight: + runs-on: \${{ fromJSON(inputs.runs_on || '["ubuntu-latest"]') }} + defaults: + run: + shell: bash + steps: + - uses: actions/checkout@v4 + with: + ref: \${{ inputs.target_ref }} + fetch-depth: 0 + + - name: Preflight release candidate + env: + VERSION: \${{ inputs.version }} + CHANNEL: \${{ inputs.channel }} + TARGET_REF: \${{ inputs.target_ref }} + RELEASE_ISSUE: \${{ inputs.release_issue }} + ARTIFACT_DIR: \${{ inputs.artifact_dir }} + VALIDATION_ARTIFACT_DIR: \${{ runner.temp }}/release-validation + RELEASE_NOTES_FILE: \${{ inputs.release_notes_file }} + PREP_SCRIPT: \${{ inputs.prep_script }} + PREFLIGHT_SCRIPT: \${{ inputs.preflight_script }} + BUILD_SCRIPT: \${{ inputs.build_script }} + TAG_PREFIX: \${{ inputs.tag_prefix }} + DEFAULT_BRANCH: \${{ inputs.default_branch }} + run: | + set -euo pipefail + semver='(0|[1-9][0-9]*)' + escaped_prefix="$(printf '%s' "$TAG_PREFIX" | sed -E 's/[][(){}.^$+*?|\\]/\\&/g')" + [[ "$VERSION" =~ ^\${escaped_prefix}\${semver}\\.\${semver}\\.\${semver}(-(rc|beta)\\.[0-9]+)?$ ]] || { echo "Invalid release version: $VERSION" >&2; exit 1; } + target_sha="$(git rev-parse HEAD)" + prep_status=skipped; preflight_status=skipped; build_status=skipped + if [[ -x "$PREP_SCRIPT" ]]; then + "$PREP_SCRIPT" + prep_status=passed + fi + if [[ -x "$PREFLIGHT_SCRIPT" ]]; then + "$PREFLIGHT_SCRIPT" + preflight_status=passed + fi + if [[ -x "$BUILD_SCRIPT" ]]; then + "$BUILD_SCRIPT" + build_status=passed + fi + mkdir -p "$ARTIFACT_DIR" "$(dirname "$RELEASE_NOTES_FILE")" + [[ -f "$RELEASE_NOTES_FILE" ]] || printf '# Release Notes\\n\\nCandidate: %s\\n' "$VERSION" >"$RELEASE_NOTES_FILE" + : >"$ARTIFACT_DIR/SHA256SUMS" + find "$ARTIFACT_DIR" -maxdepth 1 -type f ! -name SHA256SUMS ! -name release-evidence.json ! -name validation-evidence.json -print0 | sort -z | xargs -0 shasum -a 256 >>"$ARTIFACT_DIR/SHA256SUMS" + cat >"$ARTIFACT_DIR/release-evidence.json" </dev/null 2>&1; then + npm run check + standard_status=passed + fi + target_sha="$(git rev-parse HEAD)" + cat >"$ARTIFACT_DIR/validation-evidence.json" <&2; exit 1; } + tag_sha="$(git rev-parse "$TAG^{commit}")" + if [[ "$REQUIRE_SIGNED_TAG" == "true" ]]; then + git tag -v "$TAG" >/dev/null + fi + rm -rf "$ARTIFACT_DIR"; mkdir -p "$ARTIFACT_DIR" + PREFLIGHT_ARTIFACT_DIR="$(mktemp -d "\${RUNNER_TEMP:-/tmp}/release-preflight.XXXXXX")" + gh run download "$PREFLIGHT_RUN_ID" --repo "$GITHUB_REPOSITORY" --name release-package --dir "$PREFLIGHT_ARTIFACT_DIR" + [[ -f "$PREFLIGHT_ARTIFACT_DIR/release-evidence.json" ]] || { echo "Missing preflight release-evidence.json." >&2; exit 1; } + evidence_target_sha="$(node -e 'const fs=require("fs"); const p=JSON.parse(fs.readFileSync(process.argv[1],"utf8")); if (!p.target_sha) process.exit(2); process.stdout.write(p.target_sha)' "$PREFLIGHT_ARTIFACT_DIR/release-evidence.json")" + [[ "$evidence_target_sha" == "$tag_sha" ]] || { echo "Preflight evidence target SHA does not match tag SHA." >&2; exit 1; } + evidence_preflight_run_id="$(node -e 'const fs=require("fs"); const p=JSON.parse(fs.readFileSync(process.argv[1],"utf8")); if (!p.preflight_run_id) process.exit(2); process.stdout.write(p.preflight_run_id)' "$PREFLIGHT_ARTIFACT_DIR/release-evidence.json")" + [[ "$evidence_preflight_run_id" == "$PREFLIGHT_RUN_ID" ]] || { echo "Preflight evidence run ID does not match the requested preflight run." >&2; exit 1; } + VALIDATION_ARTIFACT_DIR="$(mktemp -d "\${RUNNER_TEMP:-/tmp}/release-validation.XXXXXX")" + gh run download "$VALIDATION_RUN_ID" --repo "$GITHUB_REPOSITORY" --name release-evidence-validation --dir "$VALIDATION_ARTIFACT_DIR" + [[ -f "$VALIDATION_ARTIFACT_DIR/validation-evidence.json" ]] || { echo "Missing validation validation-evidence.json." >&2; exit 1; } + validation_target_sha="$(node -e 'const fs=require("fs"); const p=JSON.parse(fs.readFileSync(process.argv[1],"utf8")); if (!p.target_sha) process.exit(2); process.stdout.write(p.target_sha)' "$VALIDATION_ARTIFACT_DIR/validation-evidence.json")" + [[ "$validation_target_sha" == "$tag_sha" ]] || { echo "Validation evidence target SHA does not match tag SHA." >&2; exit 1; } + validation_run_id="$(node -e 'const fs=require("fs"); const p=JSON.parse(fs.readFileSync(process.argv[1],"utf8")); if (!p.validation_run_id) process.exit(2); process.stdout.write(p.validation_run_id)' "$VALIDATION_ARTIFACT_DIR/validation-evidence.json")" + [[ "$validation_run_id" == "$VALIDATION_RUN_ID" ]] || { echo "Validation evidence run ID does not match the requested validation run." >&2; exit 1; } + validation_repo="$(node -e 'const fs=require("fs"); const p=JSON.parse(fs.readFileSync(process.argv[1],"utf8")); if (!p.repo) process.exit(2); process.stdout.write(p.repo)' "$VALIDATION_ARTIFACT_DIR/validation-evidence.json")" + [[ "$validation_repo" == "$GITHUB_REPOSITORY" ]] || { echo "Validation evidence repo does not match the current repository." >&2; exit 1; } + gh run view "$VALIDATION_RUN_ID" --repo "$GITHUB_REPOSITORY" --json conclusion --jq '.conclusion' | grep -qx success + if [[ -x "$PUBLISH_SCRIPT" ]]; then + "$PUBLISH_SCRIPT" + fi + if [[ "$CREATE_GITHUB_RELEASE" == "true" ]]; then + [[ -f "$PREFLIGHT_ARTIFACT_DIR/SHA256SUMS" ]] || { echo "Missing preflight SHA256SUMS manifest." >&2; exit 1; } + RELEASE_ASSET_DIR="\$(mktemp -d "\${RUNNER_TEMP:-/tmp}/release-assets.XXXXXX")" + [[ "$RELEASE_ASSET_DIR" != "$PREFLIGHT_ARTIFACT_DIR" && "$RELEASE_ASSET_DIR" != "$VALIDATION_ARTIFACT_DIR" ]] || { echo "Release asset staging directory must be isolated from evidence download directories." >&2; exit 1; } + release_assets=() + while read -r asset_sha asset_path; do + [[ -n "\${asset_sha:-}" && -n "\${asset_path:-}" ]] || continue + [[ "$asset_path" != *"release-evidence.json" && "$asset_path" != *"validation-evidence.json" ]] || continue + [[ -f "$PREFLIGHT_ARTIFACT_DIR/$asset_path" ]] || { echo "Missing preflight release asset: $asset_path" >&2; exit 1; } + asset_name="\$(basename "$asset_path")" + cp -p -- "$PREFLIGHT_ARTIFACT_DIR/$asset_path" "$RELEASE_ASSET_DIR/$asset_name" + release_assets+=("$RELEASE_ASSET_DIR/$asset_name") + done < "$PREFLIGHT_ARTIFACT_DIR/SHA256SUMS" + [[ \${#release_assets[@]} -gt 0 ]] || { echo "No release assets were staged for upload." >&2; exit 1; } + release_args=() + [[ "$TAG" == *"-rc."* || "$TAG" == *"-beta."* ]] && release_args+=(--prerelease --latest=false) + gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1 \ + && gh release upload "$TAG" "\${release_assets[@]}" --repo "$GITHUB_REPOSITORY" --clobber \ + || gh release create "$TAG" "\${release_assets[@]}" --repo "$GITHUB_REPOSITORY" --notes-file "$RELEASE_NOTES_FILE" "\${release_args[@]}" + fi + if [[ "$TAG" != *"-rc."* && "$TAG" != *"-beta."* ]]; then + version="\${TAG#"$TAG_PREFIX"}"; IFS=. read -r major minor patch <<<"$version" + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + [[ "$UPDATE_MINOR_TAG" == "true" ]] && git tag -f "\${TAG_PREFIX}\${major}.\${minor}" "$tag_sha" && git push -f origin "refs/tags/\${TAG_PREFIX}\${major}.\${minor}" + [[ "$UPDATE_MAJOR_TAG" == "true" ]] && git tag -f "\${TAG_PREFIX}\${major}" "$tag_sha" && git push -f origin "refs/tags/\${TAG_PREFIX}\${major}" + fi + if [[ -x "$POSTPUBLISH_SCRIPT" ]]; then + "$POSTPUBLISH_SCRIPT" "$TAG" + elif [[ "$REQUIRE_POSTPUBLISH_VERIFICATION" == "true" ]]; then + echo "Postpublish verification script is required but missing or not executable." >&2 + exit 1 + fi + printf '{"schema_version":1,"repo":"%s","tag":"%s","tag_sha":"%s","publish_run_id":"%s"}\\n' "$GITHUB_REPOSITORY" "$TAG" "$tag_sha" "$GITHUB_RUN_ID" >"$ARTIFACT_DIR/postpublish-evidence.json" + - uses: actions/upload-artifact@v4 + with: + name: \${{ inputs.evidence_artifact_name }}-publish + path: \${{ inputs.artifact_dir }}/postpublish-evidence.json + `; +} + +function releasePostpublishReusableWorkflow(): string { + return dedent` + name: Reusable Release Postpublish + + on: + workflow_call: + inputs: + tag: { required: true, type: string } + channel: { required: true, type: string } + release_issue: { required: false, type: string, default: "" } + postpublish_script: { required: false, type: string, default: scripts/release/postpublish.sh } + artifact_dir: { required: false, type: string, default: dist/release } + evidence_artifact_name: { required: false, type: string, default: release-evidence } + + permissions: + contents: read + actions: read + + jobs: + postpublish: + runs-on: ubuntu-latest + defaults: + run: + shell: bash + steps: + - uses: actions/checkout@v4 + with: + ref: \${{ inputs.tag }} + fetch-depth: 0 + - name: Verify published release + env: + GH_TOKEN: \${{ github.token }} + TAG: \${{ inputs.tag }} + CHANNEL: \${{ inputs.channel }} + RELEASE_ISSUE: \${{ inputs.release_issue }} + POSTPUBLISH_SCRIPT: \${{ inputs.postpublish_script }} + ARTIFACT_DIR: \${{ inputs.artifact_dir }} + run: | + set -euo pipefail + mkdir -p "$ARTIFACT_DIR" + gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null + asset_count="$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json assets --jq '.assets | length')" + [[ "$asset_count" -ge 1 ]] || { echo "Release has no assets." >&2; exit 1; } + if [[ -x "$POSTPUBLISH_SCRIPT" ]]; then + "$POSTPUBLISH_SCRIPT" "$TAG" + else + echo "Postpublish verification hook is missing or not executable: $POSTPUBLISH_SCRIPT" >&2 + exit 1 + fi + printf '{"schema_version":1,"repo":"%s","tag":"%s","channel":"%s","release_issue":"%s","postpublish_run_id":"%s","release_assets":%s}\\n' "$GITHUB_REPOSITORY" "$TAG" "$CHANNEL" "$RELEASE_ISSUE" "$GITHUB_RUN_ID" "$asset_count" >"$ARTIFACT_DIR/postpublish-evidence.json" + - uses: actions/upload-artifact@v4 + with: + name: \${{ inputs.evidence_artifact_name }}-postpublish + path: \${{ inputs.artifact_dir }}/postpublish-evidence.json + `; +} + +function releaseTrainIssueTemplate(): string { + return dedent` + name: Release Train + about: Governed release train for an OMT-Global repository + title: "Release: vX.Y.Z" + labels: + - release:train + - review:release + body: + - type: input + id: version + attributes: + label: Version + placeholder: v1.2.3 or v1.2.3-rc.1 + validations: + required: true + - type: dropdown + id: channel + attributes: + label: Channel + options: + - rc + - beta + - stable + - maintenance + validations: + required: true + - type: input + id: release_branch + attributes: + label: Release branch + placeholder: release/1.2 + - type: input + id: target_sha + attributes: + label: Target SHA + placeholder: Full commit SHA for the release candidate + - type: textarea + id: scope + attributes: + label: Scope + description: User-facing changes, fixes, risks, exclusions. + validations: + required: true + - type: textarea + id: gates + attributes: + label: Gates + value: | + - [ ] Release branch created + - [ ] Scope locked + - [ ] Changelog/release notes prepared + - [ ] Version surfaces updated + - [ ] Preflight passed + - [ ] preflight_run_id recorded: + - [ ] Full validation passed + - [ ] validation_run_id recorded: + - [ ] Exact tag created + - [ ] Publish approval granted + - [ ] Artifacts published + - [ ] GitHub Release created or updated + - [ ] Release evidence uploaded + - [ ] Postpublish verification passed + - [ ] Floating tags/channels promoted if applicable + - [ ] Release issue closed + `; +} + +function governedReleaseHookScripts(): RenderedFile[] { + return [ + { + path: "scripts/release/prep.sh", + reason: "Governed release preparation hook", + executable: true, + contents: "#!/usr/bin/env bash\nset -euo pipefail\n\necho \"No repo-specific release prep step is configured.\"\n" + }, + { + path: "scripts/release/preflight.sh", + reason: "Governed release preflight hook", + executable: true, + contents: + "#!/usr/bin/env bash\nset -euo pipefail\n\nif [[ -x scripts/ci/run-fast-checks.sh ]]; then\n bash scripts/ci/run-fast-checks.sh\nelse\n echo \"No fast-check script found. Skipping release preflight checks.\"\nfi\n" + }, + { + path: "scripts/release/validate.sh", + reason: "Governed full release validation hook", + executable: true, + contents: + "#!/usr/bin/env bash\nset -euo pipefail\n\nif [[ -x scripts/ci/run-extended-validation.sh ]]; then\n bash scripts/ci/run-extended-validation.sh\nelif [[ -x scripts/ci/run-fast-checks.sh ]]; then\n bash scripts/ci/run-fast-checks.sh\nelse\n echo \"No validation script found. Skipping release validation checks.\"\nfi\n" + }, + { + path: "scripts/release/build.sh", + reason: "Governed release artifact build hook", + executable: true, + contents: dedent` + #!/usr/bin/env bash + set -euo pipefail + + artifact_dir="dist/release" + mkdir -p "\${artifact_dir}" + + if [[ ! -f "\${artifact_dir}/artifact-manifest.json" ]]; then + cat >"\${artifact_dir}/artifact-manifest.json" <"\${artifact_dir}/RELEASE_NOTES.md" + fi + + echo "Prepared release artifact directory \${artifact_dir}." + ` + }, + { + path: "scripts/release/publish.sh", + reason: "Governed release publish hook", + executable: true, + contents: + "#!/usr/bin/env bash\nset -euo pipefail\n\necho \"No external package publish step is configured.\"\necho \"The reusable publish workflow creates or updates the GitHub Release from the preflight artifact.\"\n" + }, + { + path: "scripts/release/postpublish.sh", + reason: "Governed release postpublish verification hook", + executable: true, + contents: dedent` + #!/usr/bin/env bash + set -euo pipefail + + tag="\${1:-\${GITHUB_REF_NAME:-}}" + if [[ -n "\${tag}" && -n "\${GITHUB_REPOSITORY:-}" && -n "\${GH_TOKEN:-}" ]]; then + gh release view "\${tag}" --repo "\${GITHUB_REPOSITORY}" >/dev/null + echo "GitHub Release \${tag} exists." + else + echo "No GitHub release lookup context available. Skipping postpublish remote check." + fi + ` + } + ]; +} + +function releaseTrainDoc(): string { + return dedent` + # Governed Release Train + + This repository uses release maturity level \`governed\`. + + ## Manual Flow + + 1. Open a release train issue with \`.github/ISSUE_TEMPLATE/release_train.yml\`. + 2. Create or update the \`release/{major}.{minor}\` release branch. + 3. Run \`Release Preflight\` with the candidate version, channel, target ref, and release issue. + 4. Copy the successful preflight run ID into the release issue. + 5. Run \`Full Release Validation\` against the same target ref. + 6. Copy the successful validation run ID into the release issue. + 7. Create the exact release tag only after validation evidence exists. + 8. Run \`Release Publish\` with the tag, preflight run ID, validation run ID, channel, and release issue. + 9. Run or review \`Release Postpublish\`, then close or supersede the release issue. + + Publish must consume the artifact bundle proven by preflight. If the preflight artifact cannot be downloaded or its recorded target SHA differs from the tag SHA, publish must fail instead of rebuilding. + + ## Customization + + Repo-specific behavior belongs in these hook scripts: + + - \`scripts/release/prep.sh\` + - \`scripts/release/preflight.sh\` + - \`scripts/release/validate.sh\` + - \`scripts/release/build.sh\` + - \`scripts/release/publish.sh\` + - \`scripts/release/postpublish.sh\` + + The generated defaults do not require secrets and do not publish external packages. + `; +} + +function releaseTrainContractDoc(): string { + return dedent` + # Governed Release Train Contract + + Bootstrap supports four release maturity levels: + + | Level | Name | Behavior | + | ---: | --- | --- | + | 0 | \`none\` | No managed release files are generated. | + | 1 | \`simple\` | Existing tag-triggered SemVer release workflow remains available. | + | 2 | \`governed\` | Adds release preflight, full validation, publish orchestration, postpublish verification, and release evidence. | + | 3 | \`regulated\` | Uses governed release flow plus stricter gates where supported, including signed tag verification when required. | + + Backwards compatibility is intentional: \`release.enabled: true\` without \`release.maturity\` is treated as \`simple\`, and \`release.enabled: false\` is treated as \`none\`. + + ## Configure + + \`\`\`yaml + release: + enabled: true + maturity: governed + reusableWorkflowRepo: OMT-Global/bootstrap + reusableWorkflowRef: refs/heads/main + \`\`\` + + Governed repos receive thin caller workflows for preflight, validation, publish, and postpublish verification. Package-specific behavior belongs in hook scripts under \`scripts/release/\`. + + ## Manual Release Flow + + 1. Open a release train issue. + 2. Create or update the release branch. + 3. Run \`Release Preflight\` and record \`preflight_run_id\`. + 4. Run \`Full Release Validation\` and record \`validation_run_id\`. + 5. Create the exact tag after validation evidence exists. + 6. Run \`Release Publish\` with the tag, \`preflight_run_id\`, and \`validation_run_id\`. + 7. Verify postpublish evidence and close or supersede the release issue. + + Publish must consume the artifact bundle from the preflight run. If the preflight artifact cannot be downloaded, or if evidence does not match the tag SHA, publish fails rather than rebuilding. + + ## Secrets + + Generated hooks are safe no-ops by default. Production credentials belong in GitHub environments, secrets, packages, or OIDC configuration, not in manifests or generated scripts. + `; +} + +function releaseEvidenceSchemaDoc(): string { + return dedent` + # Release Evidence Schema + + Governed releases emit machine-readable evidence into \`dist/release/\`. + + Minimum release evidence: + + \`\`\`json + { + "schema_version": 1, + "repo": "OMT-Global/example", + "version": "v1.2.3", + "channel": "stable", + "target_ref": "release/1.2", + "target_sha": "full_sha", + "release_issue": "123", + "preflight_run_id": "123456789", + "validation_run_id": "123456790", + "publish_run_id": "123456791", + "artifacts": [{ "name": "example-v1.2.3.tar.gz", "sha256": "..." }], + "checks": { + "prep": "passed", + "preflight": "passed", + "build": "passed", + "full_validation": "passed", + "publish": "passed", + "postpublish": "passed" + } + } + \`\`\` + + Expected files: + + - \`dist/release/release-evidence.json\` + - \`dist/release/postpublish-evidence.json\` + - \`dist/release/SHA256SUMS\` + - \`dist/release/RELEASE_NOTES.md\` + + Evidence links the release issue, target SHA, workflow run IDs, artifact checksums, release notes, and postpublish status so a release can be audited without relying on local operator state. + `; +} + function prWorkflow(manifest: BootstrapManifest): string { const paths = workflowPaths(manifest); const shellRunner = formatRunsOn(resolveRunsOn(manifest.ci.runnerPolicy, manifest.project.visibility, ["shell"])); @@ -1850,6 +2644,7 @@ function releaseVersioningDoc(manifest: BootstrapManifest): string { } export function renderManagedFiles(manifest: BootstrapManifest): RenderedFile[] { + const releaseIsGoverned = manifest.release.maturity === "governed" || manifest.release.maturity === "regulated"; const files: RenderedFile[] = [ { path: "project.bootstrap.yaml", @@ -1911,6 +2706,15 @@ export function renderManagedFiles(manifest: BootstrapManifest): RenderedFile[] } ] : []), + ...(releaseIsGoverned + ? [ + { + path: ".github/ISSUE_TEMPLATE/release_train.yml", + reason: "Governed release train issue template", + contents: `${releaseTrainIssueTemplate()}\n` + } + ] + : []), { path: ".github/workflows/pr-fast-ci.yml", reason: "Fast pull request workflow", @@ -1930,7 +2734,7 @@ export function renderManagedFiles(manifest: BootstrapManifest): RenderedFile[] } ] : []), - ...(manifest.release.enabled + ...(manifest.release.enabled && !releaseIsGoverned ? [ { path: ".github/workflows/release-tag.yml", @@ -1939,6 +2743,50 @@ export function renderManagedFiles(manifest: BootstrapManifest): RenderedFile[] } ] : []), + ...(releaseIsGoverned + ? [ + { + path: ".github/workflows/release-preflight.yml", + reason: "Governed release preflight caller", + contents: `${releasePreflightCallerWorkflow(manifest)}\n` + }, + { + path: ".github/workflows/full-release-validation.yml", + reason: "Governed full release validation caller", + contents: `${fullReleaseValidationCallerWorkflow(manifest)}\n` + }, + { + path: ".github/workflows/release-publish.yml", + reason: "Governed release publish caller", + contents: `${releasePublishCallerWorkflow(manifest)}\n` + }, + { + path: ".github/workflows/release-postpublish.yml", + reason: "Governed release postpublish caller", + contents: `${releasePostpublishCallerWorkflow(manifest)}\n` + }, + { + path: ".github/workflows/release-preflight-reusable.yml", + reason: "Reusable governed release preflight workflow", + contents: `${releasePreflightReusableWorkflow()}\n` + }, + { + path: ".github/workflows/full-release-validation-reusable.yml", + reason: "Reusable governed full release validation workflow", + contents: `${fullReleaseValidationReusableWorkflow()}\n` + }, + { + path: ".github/workflows/release-publish-reusable.yml", + reason: "Reusable governed release publish workflow", + contents: `${releasePublishReusableWorkflow()}\n` + }, + { + path: ".github/workflows/release-postpublish-reusable.yml", + reason: "Reusable governed release postpublish workflow", + contents: `${releasePostpublishReusableWorkflow()}\n` + } + ] + : []), ...(manifest.release.enabled && manifest.release.changelog.enabled ? [ { @@ -2003,6 +2851,7 @@ export function renderManagedFiles(manifest: BootstrapManifest): RenderedFile[] } ] : []), + ...(releaseIsGoverned ? governedReleaseHookScripts() : []), { path: "scripts/codex-cloud/setup.sh", reason: "Codex cloud setup script", @@ -2034,6 +2883,25 @@ export function renderManagedFiles(manifest: BootstrapManifest): RenderedFile[] } ] : []), + ...(releaseIsGoverned + ? [ + { + path: "docs/bootstrap/release-train-contract.md", + reason: "Governed release train contract", + contents: `${releaseTrainContractDoc()}\n` + }, + { + path: "docs/bootstrap/release-evidence-schema.md", + reason: "Governed release evidence schema", + contents: `${releaseEvidenceSchemaDoc()}\n` + }, + { + path: "docs/release-train.md", + reason: "Repo-local governed release train guide", + contents: `${releaseTrainDoc()}\n` + } + ] + : []), ]; switch (manifest.archetype.kind) { diff --git a/src/manifest.ts b/src/manifest.ts index 96fea15..eff3e53 100644 --- a/src/manifest.ts +++ b/src/manifest.ts @@ -277,6 +277,7 @@ const manifestSchema = z.object({ release: z .object({ enabled: z.boolean().optional(), + maturity: z.enum(["none", "simple", "governed", "regulated"]).optional(), tagPrefix: z.string().min(1).optional(), createGitHubRelease: z.boolean().optional(), updateMajorTag: z.boolean().optional(), @@ -484,6 +485,7 @@ export function normalizeManifest(raw: z.input): Bootstra const repoFeatures = github.repoFeatures ?? {}; const flowGovernance = github.flowGovernance ?? false; const environments = parsed.environments ?? {}; + const releaseEnabled = parsed.release?.enabled ?? true; const defaultEnvironment = (overrides?: z.input): EnvironmentConfig => ({ reviewers: overrides?.reviewers ?? [], @@ -564,7 +566,8 @@ export function normalizeManifest(raw: z.input): Bootstra } }, release: { - enabled: parsed.release?.enabled ?? true, + enabled: releaseEnabled, + maturity: releaseEnabled ? (parsed.release?.maturity ?? "simple") : "none", tagPrefix: parsed.release?.tagPrefix ?? "v", createGitHubRelease: parsed.release?.createGitHubRelease ?? true, updateMajorTag: parsed.release?.updateMajorTag ?? true, diff --git a/src/types.ts b/src/types.ts index 7bbf484..c5f35ea 100644 --- a/src/types.ts +++ b/src/types.ts @@ -59,6 +59,7 @@ export interface CustomScriptsConfig { } export type ReleaseChangelogMode = "github-generated-notes"; +export type ReleaseMaturity = "none" | "simple" | "governed" | "regulated"; export type ReleaseVersionType = "npm" | "python" | "container"; export type ReleaseChecksumType = "sha256" | "none"; export type ReleaseSbomMode = "required" | "optional" | "disabled"; @@ -184,6 +185,7 @@ export interface BootstrapManifest { }; release: { enabled: boolean; + maturity: ReleaseMaturity; tagPrefix: string; createGitHubRelease: boolean; updateMajorTag: boolean; diff --git a/test/release-workflow-guards.test.ts b/test/release-workflow-guards.test.ts new file mode 100644 index 0000000..eaf0fa9 --- /dev/null +++ b/test/release-workflow-guards.test.ts @@ -0,0 +1,85 @@ +import { readFileSync } from 'node:fs'; +import { describe, expect, it } from 'vitest'; + +const repoRoot = process.cwd(); + +function read(relativePath: string): string { + return readFileSync(new URL(relativePath, `file://${repoRoot}/`), 'utf8'); +} + +describe('governed release hook guards', () => { + it('uses fail-closed branches for release hook execution', () => { + const workflowPaths = [ + '.github/workflows/release-preflight-reusable.yml', + '.github/workflows/full-release-validation-reusable.yml' + ]; + + for (const workflowPath of workflowPaths) { + const contents = read(workflowPath); + expect(contents).toContain('if [[ -x "$'); + expect(contents).not.toContain('&& "$'); + expect(contents).not.toContain('&& prep_status=passed'); + expect(contents).not.toContain('&& preflight_status=passed'); + expect(contents).not.toContain('&& build_status=passed'); + expect(contents).not.toContain('&& validate_status=passed'); + } + + const archetypes = read('src/archetypes.ts'); + expect(archetypes).toContain('if [[ -x "$PREP_SCRIPT" ]]; then'); + expect(archetypes).toContain('if [[ -x "$PREFLIGHT_SCRIPT" ]]; then'); + expect(archetypes).toContain('if [[ -x "$BUILD_SCRIPT" ]]; then'); + expect(archetypes).toContain('if [[ -x "$VALIDATE_SCRIPT" ]]; then'); + expect(archetypes).not.toContain('[[ -x "$PREP_SCRIPT" ]] && "$PREP_SCRIPT" && prep_status=passed'); + expect(archetypes).not.toContain('[[ -x "$PREFLIGHT_SCRIPT" ]] && "$PREFLIGHT_SCRIPT" && preflight_status=passed'); + expect(archetypes).not.toContain('[[ -x "$BUILD_SCRIPT" ]] && "$BUILD_SCRIPT" && build_status=passed'); + expect(archetypes).not.toContain('[[ -x "$VALIDATE_SCRIPT" ]] && "$VALIDATE_SCRIPT" && validate_status=passed'); + expect(archetypes).toContain('cat >"$ARTIFACT_DIR/release-evidence.json" <"$RELEASE_ASSET_DIR/release-evidence.json" < { + const workflow = read('.github/workflows/release-publish-reusable.yml'); + const buildScript = read('scripts/ci/run-release-build.sh'); + expect(workflow).toContain('gh run download "$PREFLIGHT_RUN_ID" --repo "$GITHUB_REPOSITORY" --name release-package --dir "$PREFLIGHT_ARTIFACT_DIR"'); + expect(workflow).toContain('[[ "$evidence_target_sha" == "$tag_sha" ]] || { echo "Preflight evidence target SHA does not match tag SHA." >&2; exit 1; }'); + expect(workflow).toContain('[[ "$evidence_preflight_run_id" == "$PREFLIGHT_RUN_ID" ]] || { echo "Preflight evidence run ID does not match the requested preflight run." >&2; exit 1; }'); + expect(workflow).toContain('gh run download "$VALIDATION_RUN_ID" --repo "$GITHUB_REPOSITORY" --name release-evidence-validation --dir "$VALIDATION_ARTIFACT_DIR"'); + expect(workflow).not.toContain('"$ARTIFACT_DIR/validation"'); + expect(workflow).not.toContain('"$ARTIFACT_DIR"/*'); + expect(workflow).toContain('[[ "$validation_target_sha" == "$tag_sha" ]] || { echo "Validation evidence target SHA does not match tag SHA." >&2; exit 1; }'); + expect(workflow).toContain('[[ "$validation_run_id" == "$VALIDATION_RUN_ID" ]] || { echo "Validation evidence run ID does not match the requested validation run." >&2; exit 1; }'); + expect(workflow).toContain('[[ "$validation_repo" == "$GITHUB_REPOSITORY" ]] || { echo "Validation evidence repo does not match the current repository." >&2; exit 1; }'); + expect(workflow).toContain("gh run view \"$VALIDATION_RUN_ID\" --repo \"$GITHUB_REPOSITORY\" --json conclusion --jq '.conclusion' | grep -qx success"); + expect(buildScript).toContain('validation-evidence.json'); + expect(buildScript).not.toContain('find "${artifact_dir}" -maxdepth 1 -type f ! -name SHA256SUMS | sort'); + expect(workflow).toContain('[[ -f "$PREFLIGHT_ARTIFACT_DIR/SHA256SUMS" ]] || { echo "Missing preflight SHA256SUMS manifest." >&2; exit 1; }'); + expect(workflow).toContain('while read -r asset_sha asset_path; do'); + expect(workflow).not.toContain('find "$ARTIFACT_DIR" -maxdepth 1 -type f ! -name SHA256SUMS -print0 | sort -z | xargs -0 shasum -a 256 >>"$ARTIFACT_DIR/SHA256SUMS"'); + expect(workflow).toContain('RELEASE_ASSET_DIR="$(mktemp -d "${RUNNER_TEMP:-/tmp}/release-assets.XXXXXX")"'); + expect(workflow).toContain('[[ "$RELEASE_ASSET_DIR" != "$PREFLIGHT_ARTIFACT_DIR" && "$RELEASE_ASSET_DIR" != "$VALIDATION_ARTIFACT_DIR" ]] || { echo "Release asset staging directory must be isolated from evidence download directories." >&2; exit 1; }'); + expect(workflow).toContain("while read -r asset_sha asset_path; do"); + expect(workflow).toContain('cp -p -- "$PREFLIGHT_ARTIFACT_DIR/$asset_path" "$RELEASE_ASSET_DIR/$asset_name"'); + expect(workflow).toContain('release_assets+=("$RELEASE_ASSET_DIR/$asset_name")'); + expect(workflow).toContain('[[ ${#release_assets[@]} -gt 0 ]] || { echo "No release assets were staged for upload." >&2; exit 1; }'); + expect(workflow).not.toContain('find "$PREFLIGHT_ARTIFACT_DIR" -maxdepth 1 -type f \( ! -name release-evidence.json ! -name validation-evidence.json \) -exec cp -p {} "$RELEASE_ASSET_DIR" \;'); + expect(workflow).toContain('gh release upload "$TAG"'); + expect(workflow).toContain('gh release create "$TAG"'); + expect(workflow).not.toContain('gh release upload "$TAG" "$ARTIFACT_DIR"/*'); + expect(workflow).not.toContain('gh release create "$TAG" "$ARTIFACT_DIR"/*'); + + const archetypes = read('src/archetypes.ts'); + expect(archetypes).toContain('gh run download "$PREFLIGHT_RUN_ID" --repo "$GITHUB_REPOSITORY" --name release-package --dir "$PREFLIGHT_ARTIFACT_DIR"'); + expect(archetypes).toContain('[[ "$evidence_target_sha" == "$tag_sha" ]] || { echo "Preflight evidence target SHA does not match tag SHA." >&2; exit 1; }'); + expect(archetypes).toContain('[[ "$evidence_preflight_run_id" == "$PREFLIGHT_RUN_ID" ]] || { echo "Preflight evidence run ID does not match the requested preflight run." >&2; exit 1; }'); + expect(archetypes).toContain('gh release upload "$TAG"'); + expect(archetypes).toContain('gh release create "$TAG"'); + expect(archetypes).toContain('gh run download "$VALIDATION_RUN_ID" --repo "$GITHUB_REPOSITORY" --name release-evidence-validation --dir "$VALIDATION_ARTIFACT_DIR"'); + expect(archetypes).not.toContain('"$ARTIFACT_DIR/validation"'); + expect(archetypes).not.toContain('"$ARTIFACT_DIR"/*'); + expect(archetypes).toContain('[[ "$validation_target_sha" == "$tag_sha" ]] || { echo "Validation evidence target SHA does not match tag SHA." >&2; exit 1; }'); + expect(archetypes).toContain('[[ "$validation_run_id" == "$VALIDATION_RUN_ID" ]] || { echo "Validation evidence run ID does not match the requested validation run." >&2; exit 1; }'); + expect(archetypes).toContain('[[ "$validation_repo" == "$GITHUB_REPOSITORY" ]] || { echo "Validation evidence repo does not match the current repository." >&2; exit 1; }'); + expect(archetypes).not.toContain('gh release upload "$TAG" "$ARTIFACT_DIR"/*'); + expect(archetypes).not.toContain('gh release create "$TAG" "$ARTIFACT_DIR"/*'); + }); +}); diff --git a/tests/manifest.test.ts b/tests/manifest.test.ts index 63f9fa7..87fce2e 100644 --- a/tests/manifest.test.ts +++ b/tests/manifest.test.ts @@ -40,6 +40,7 @@ describe("normalizeManifest", () => { }); expect(manifest.release).toEqual({ enabled: true, + maturity: "simple", tagPrefix: "v", createGitHubRelease: true, updateMajorTag: true, @@ -223,6 +224,7 @@ describe("normalizeManifest", () => { }, release: { enabled: true, + maturity: "governed", tagPrefix: "bootstrap-v", createGitHubRelease: false, updateMajorTag: true, @@ -256,6 +258,7 @@ describe("normalizeManifest", () => { expect(manifest.release).toEqual({ enabled: true, + maturity: "governed", tagPrefix: "bootstrap-v", createGitHubRelease: false, updateMajorTag: true, diff --git a/tests/release-postpublish.test.ts b/tests/release-postpublish.test.ts new file mode 100644 index 0000000..3d6e0b8 --- /dev/null +++ b/tests/release-postpublish.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest'; + +import { renderManagedFiles } from '../src/archetypes.js'; +import { normalizeManifest } from '../src/manifest.js'; + +describe('release postpublish rendering', () => { + it('fails closed when the postpublish hook is missing or not executable', () => { + const manifest = normalizeManifest({ + version: 1, + project: { name: 'example', owner: 'octo-org' }, + archetype: { kind: 'node-ts-service' }, + release: { enabled: true, maturity: 'governed' } + }); + + const rendered = renderManagedFiles(manifest); + const workflow = rendered.find((file) => file.path === '.github/workflows/release-postpublish-reusable.yml'); + + expect(workflow).toBeDefined(); + expect(workflow?.contents).toContain('if [[ -x "$POSTPUBLISH_SCRIPT" ]]; then'); + expect(workflow?.contents).toContain('Postpublish verification hook is missing or not executable'); + expect(workflow?.contents).not.toContain('|| true'); + }); +}); diff --git a/tests/render.test.ts b/tests/render.test.ts index 77b5652..8d19cdf 100644 --- a/tests/render.test.ts +++ b/tests/render.test.ts @@ -282,6 +282,77 @@ describe("renderManagedFiles", () => { expect(versioningDoc?.contents).toContain("Release Notes"); }); + it("renders governed release automation when requested", () => { + const manifest = normalizeManifest({ + project: { + name: "governed-release-repo", + owner: "OMT-Global" + }, + archetype: { + kind: "generic-empty" + }, + release: { + enabled: true, + maturity: "governed", + reusableWorkflowRef: "refs/tags/bootstrap-v1" + } + }); + + const files = renderManagedFiles(manifest); + const paths = files.map((file) => file.path); + const preflight = files.find((file) => file.path === ".github/workflows/release-preflight.yml"); + const publish = files.find((file) => file.path === ".github/workflows/release-publish.yml"); + const reusablePublish = files.find( + (file) => file.path === ".github/workflows/release-publish-reusable.yml" + ); + const releaseTrain = files.find((file) => file.path === "docs/release-train.md"); + const issueTemplate = files.find((file) => file.path === ".github/ISSUE_TEMPLATE/release_train.yml"); + + expect(paths).not.toContain(".github/workflows/release-tag.yml"); + expect(paths).toContain(".github/workflows/release-preflight-reusable.yml"); + expect(paths).toContain(".github/workflows/full-release-validation-reusable.yml"); + expect(paths).toContain(".github/workflows/release-publish-reusable.yml"); + expect(paths).toContain(".github/workflows/release-postpublish-reusable.yml"); + expect(paths).toContain("scripts/release/preflight.sh"); + expect(paths).toContain("scripts/release/postpublish.sh"); + expect(paths).toContain("docs/bootstrap/release-evidence-schema.md"); + expect(preflight?.contents).toContain( + "uses: OMT-Global/bootstrap/.github/workflows/release-preflight-reusable.yml@refs/tags/bootstrap-v1" + ); + expect(publish?.contents).toContain("require_release_issue: true"); + expect(publish?.contents).toContain("require_signed_tag: false"); + expect(reusablePublish?.contents).toContain("gh run download"); + expect(reusablePublish?.contents).toContain("PREFLIGHT_ARTIFACT_DIR"); + expect(reusablePublish?.contents).toContain("VALIDATION_ARTIFACT_DIR"); + expect(reusablePublish?.contents).toContain("SHA256SUMS"); + expect(reusablePublish?.contents).toContain("RELEASE_ASSET_DIR"); + expect(reusablePublish?.contents).toContain("while read -r asset_sha asset_path; do"); + expect(reusablePublish?.contents).toContain('[[ -f "$PREFLIGHT_ARTIFACT_DIR/SHA256SUMS" ]] || { echo "Missing preflight SHA256SUMS manifest." >&2; exit 1; }'); + expect(reusablePublish?.contents).toContain('[[ "$asset_path" != *"release-evidence.json" && "$asset_path" != *"validation-evidence.json" ]] || continue'); + expect(reusablePublish?.contents).not.toContain('find "$ARTIFACT_DIR" -maxdepth 1 -type f ! -name SHA256SUMS -print0 | sort -z | xargs -0 shasum -a 256 >>"$ARTIFACT_DIR/SHA256SUMS"'); + expect(reusablePublish?.contents).toContain('cp -p -- "$PREFLIGHT_ARTIFACT_DIR/$asset_path" "$RELEASE_ASSET_DIR/$asset_name"'); + expect(reusablePublish?.contents).toContain('release_assets+=("$RELEASE_ASSET_DIR/$asset_name")'); + expect(reusablePublish?.contents).not.toContain('gh release upload "$TAG" "$ARTIFACT_DIR"/*'); + expect(reusablePublish?.contents).not.toContain('gh release upload "$TAG" "${release_assets[@]}" "$ARTIFACT_DIR"/*'); + expect(reusablePublish?.contents).not.toContain('find "$ARTIFACT_DIR" -maxdepth 1 -type f'); + expect(reusablePublish?.contents).toContain('done < "$PREFLIGHT_ARTIFACT_DIR/SHA256SUMS"'); + expect(reusablePublish?.contents).toContain("UPDATE_MAJOR_TAG"); + expect(reusablePublish?.contents).toContain( + "Preflight evidence run ID does not match the requested preflight run." + ); + expect(reusablePublish?.contents).toContain("Preflight evidence target SHA does not match tag SHA."); + expect(reusablePublish?.contents).toContain("Validation evidence target SHA does not match tag SHA."); + expect(reusablePublish?.contents).toContain("Validation evidence run ID does not match the requested validation run."); + expect(reusablePublish?.contents).toContain("release-evidence-validation"); + const reusableValidation = files.find((file) => file.path === ".github/workflows/full-release-validation-reusable.yml"); + expect(reusableValidation?.contents).toContain("name: ${{ inputs.evidence_artifact_name }}-validation"); + expect(reusableValidation?.contents).not.toContain("inputs.release_package_artifact_name"); + expect(reusablePublish?.contents).not.toContain("|| true"); + expect(reusablePublish?.contents).toContain("Postpublish verification script is required but missing or not executable."); + expect(releaseTrain?.contents).toContain("Publish must consume the artifact bundle proven by preflight"); + expect(issueTemplate?.contents).toContain("preflight_run_id recorded"); + }); + it("renders npm and python version validation in the release version hook", () => { const manifest = normalizeManifest({ project: { diff --git a/tests/reusable-workflows.test.ts b/tests/reusable-workflows.test.ts index 089ed72..9eafbb8 100644 --- a/tests/reusable-workflows.test.ts +++ b/tests/reusable-workflows.test.ts @@ -83,7 +83,55 @@ describe("reusable workflows", () => { expect(workflow.name).toBe("Reusable AI Attestation"); expect((workflow.on as any).workflow_call.inputs["artifact_name"].default).toBe("ai-attestation"); expect((workflow.on as any).workflow_call.inputs["retention_days"].default).toBe(90); - expect((workflow.jobs as any).attest).toBeTruthy(); - expect((workflow.jobs as any).verify).toBeTruthy(); + expect((workflow.jobs as any).attest["runs-on"]).toBe("ubuntu-latest"); + expect((workflow.jobs as any).verify["runs-on"]).toBe("ubuntu-latest"); + }); + + it("defines governed release train reusable workflow contracts", () => { + const preflight = loadWorkflow(".github/workflows/release-preflight-reusable.yml"); + const validation = loadWorkflow(".github/workflows/full-release-validation-reusable.yml"); + const publish = loadWorkflow(".github/workflows/release-publish-reusable.yml"); + const postpublish = loadWorkflow(".github/workflows/release-postpublish-reusable.yml"); + + expect(preflight.name).toBe("Reusable Release Preflight"); + expect((preflight.on as any).workflow_call.inputs.version.required).toBe(true); + expect((validation.on as any).workflow_call.inputs.evidence_artifact_name.default).toBe("release-evidence"); + expect((validation.on as any).workflow_call.inputs.release_package_artifact_name).toBeUndefined(); + expect((validation.jobs as any).validate.steps.some((step: any) => step.uses === "actions/upload-artifact@v4" && step.with?.name === "${{ inputs.evidence_artifact_name }}-validation")).toBe(true); + expect((preflight.jobs as any).preflight).toBeTruthy(); + expect(validation.name).toBe("Reusable Full Release Validation"); + expect((validation.jobs as any).validate).toBeTruthy(); + expect(publish.name).toBe("Reusable Release Publish"); + expect(JSON.stringify(publish)).not.toContain("|| true"); + expect((publish.on as any).workflow_call.inputs.require_release_issue.default).toBe(true); + expect((publish.on as any).workflow_call.inputs.default_branch.default).toBe("main"); + expect((publish.jobs as any).publish.environment).toBe( + "${{ inputs.publish_environment || 'release-publish' }}" + ); + expect((publish.jobs as any).publish.steps[1].run).toContain("PREFLIGHT_ARTIFACT_DIR"); + expect((publish.jobs as any).publish.steps[1].run).toContain("SHA256SUMS"); + expect((publish.jobs as any).publish.steps[1].run).toContain("RELEASE_ASSET_DIR"); + expect((publish.jobs as any).publish.steps[1].run).toContain("while read -r asset_sha asset_path; do"); + expect((publish.jobs as any).publish.steps[1].run).toContain('[[ -f "$PREFLIGHT_ARTIFACT_DIR/SHA256SUMS" ]] || { echo "Missing preflight SHA256SUMS manifest." >&2; exit 1; }'); + expect((publish.jobs as any).publish.steps[1].run).toContain('[[ "$asset_path" != *"release-evidence.json" && "$asset_path" != *"validation-evidence.json" ]] || continue'); + expect((publish.jobs as any).publish.steps[1].run).toContain('cp -p -- "$PREFLIGHT_ARTIFACT_DIR/$asset_path" "$RELEASE_ASSET_DIR/$asset_name"'); + expect((publish.jobs as any).publish.steps[1].run).toContain('release_assets+=("$RELEASE_ASSET_DIR/$asset_name")'); + expect((publish.jobs as any).publish.steps[1].run).toContain('[[ ${#release_assets[@]} -gt 0 ]] || { echo "No release assets were staged for upload." >&2; exit 1; }'); + expect((publish.jobs as any).publish.steps[1].run).not.toContain('gh release upload "$TAG" "$ARTIFACT_DIR"/*'); + expect((publish.jobs as any).publish.steps[1].run).toContain( + 'Preflight evidence run ID does not match the requested preflight run.' + ); + expect((publish.jobs as any).publish.steps[1].run).toContain("Preflight evidence target SHA does not match tag SHA."); + expect((publish.jobs as any).publish.steps[1].run).toContain("Validation evidence target SHA does not match tag SHA."); + expect((publish.jobs as any).publish.steps[1].run).toContain("release-evidence-validation"); + expect((publish.jobs as any).publish.steps[1].run).toContain("release-evidence.json"); + expect((publish.jobs as any).publish.steps[1].run).toContain("SHA256SUMS"); + expect((publish.jobs as any).publish.steps[1].run).toContain("while read -r asset_sha asset_path; do"); + expect((publish.jobs as any).publish.steps[1].run).toContain("Validation evidence run ID does not match the requested validation run."); + expect((publish.jobs as any).publish.steps[1].run).toContain("Validation evidence repo does not match the current repository."); + expect(postpublish.name).toBe("Reusable Release Postpublish"); + expect((postpublish.jobs as any).postpublish).toBeTruthy(); + expect(JSON.stringify(postpublish)).not.toContain("|| true"); + expect(JSON.stringify(postpublish)).toContain("Postpublish verification hook is missing or not executable"); }); }); diff --git a/vitest.config.ts b/vitest.config.ts index 651c979..a9639f9 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -3,6 +3,6 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ test: { environment: "node", - include: ["tests/**/*.test.ts"] + include: ["tests/**/*.test.ts", "test/**/*.test.ts"] } });