From 27c7ccad5fc8b9041b7f2aa4d16b8d7a26f9b89b Mon Sep 17 00:00:00 2001 From: Your Name Date: Mon, 1 Jun 2026 10:28:35 +0100 Subject: [PATCH 01/29] Implement governed release automation Adds release.maturity support with governed release-train workflows, evidence docs, issue template, and default repo hook scripts.\n\nReplaces the simple tag-triggered caller for this repo with governed preflight, validation, publish, and postpublish orchestration.\n\nCloses #28.\nRefs #27. --- .github/ISSUE_TEMPLATE/release_train.yml | 63 ++ .github/dependabot.yml | 27 + .github/workflows/ai-attestation-reusable.yml | 60 +- .../full-release-validation-reusable.yml | 53 ++ .github/workflows/full-release-validation.yml | 27 + .../release-postpublish-reusable.yml | 47 + .github/workflows/release-postpublish.yml | 30 + .../workflows/release-preflight-reusable.yml | 82 ++ .github/workflows/release-preflight.yml | 41 + .../workflows/release-publish-reusable.yml | 91 ++ .github/workflows/release-publish.yml | 52 ++ .github/workflows/release-tag.yml | 27 - docs/bootstrap/release-evidence-schema.md | 38 + docs/bootstrap/release-train-contract.md | 40 + docs/release-train.md | 30 + project.bootstrap.yaml | 1 + scripts/release/build.sh | 24 + scripts/release/postpublish.sh | 10 + scripts/release/preflight.sh | 8 + scripts/release/prep.sh | 4 + scripts/release/publish.sh | 5 + scripts/release/validate.sh | 10 + src/archetypes.ts | 814 +++++++++++++++++- src/manifest.ts | 5 +- src/types.ts | 2 + tests/manifest.test.ts | 3 + tests/render.test.ts | 45 + tests/reusable-workflows.test.ts | 24 +- 28 files changed, 1574 insertions(+), 89 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/release_train.yml create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/full-release-validation-reusable.yml create mode 100644 .github/workflows/full-release-validation.yml create mode 100644 .github/workflows/release-postpublish-reusable.yml create mode 100644 .github/workflows/release-postpublish.yml create mode 100644 .github/workflows/release-preflight-reusable.yml create mode 100644 .github/workflows/release-preflight.yml create mode 100644 .github/workflows/release-publish-reusable.yml create mode 100644 .github/workflows/release-publish.yml delete mode 100644 .github/workflows/release-tag.yml create mode 100644 docs/bootstrap/release-evidence-schema.md create mode 100644 docs/bootstrap/release-train-contract.md create mode 100644 docs/release-train.md create mode 100755 scripts/release/build.sh create mode 100755 scripts/release/postpublish.sh create mode 100755 scripts/release/preflight.sh create mode 100755 scripts/release/prep.sh create mode 100755 scripts/release/publish.sh create mode 100755 scripts/release/validate.sh 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..4a21365 --- /dev/null +++ b/.github/workflows/full-release-validation-reusable.yml @@ -0,0 +1,53 @@ +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 + [[ -x "$VALIDATE_SCRIPT" ]] && "$VALIDATE_SCRIPT" && validate_status=passed + 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; } + [[ -x "$POSTPUBLISH_SCRIPT" ]] && "$POSTPUBLISH_SCRIPT" "$TAG" || true + 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..850df42 --- /dev/null +++ b/.github/workflows/release-preflight-reusable.yml @@ -0,0 +1,82 @@ +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 + [[ -x "$PREP_SCRIPT" ]] && "$PREP_SCRIPT" && prep_status=passed + [[ -x "$PREFLIGHT_SCRIPT" ]] && "$PREFLIGHT_SCRIPT" && preflight_status=passed + [[ -x "$BUILD_SCRIPT" ]] && "$BUILD_SCRIPT" && build_status=passed + 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}")" + rm -rf "$ARTIFACT_DIR"; mkdir -p "$ARTIFACT_DIR" + gh run download "$PREFLIGHT_RUN_ID" --repo "$GITHUB_REPOSITORY" --name release-package --dir "$ARTIFACT_DIR" + [[ -f "$ARTIFACT_DIR/release-evidence.json" ]] || { echo "Missing preflight release-evidence.json." >&2; exit 1; } + gh run view "$VALIDATION_RUN_ID" --repo "$GITHUB_REPOSITORY" --json conclusion --jq '.conclusion' | grep -qx success + [[ -x "$PUBLISH_SCRIPT" ]] && "$PUBLISH_SCRIPT" + if [[ "$CREATE_GITHUB_RELEASE" == "true" ]]; then + 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" "$ARTIFACT_DIR"/* --repo "$GITHUB_REPOSITORY" --clobber \ + || gh release create "$TAG" "$ARTIFACT_DIR"/* --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 + [[ -x "$POSTPUBLISH_SCRIPT" ]] && "$POSTPUBLISH_SCRIPT" "$TAG" || true + 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/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/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/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..c936cd3 100644 --- a/src/archetypes.ts +++ b/src/archetypes.ts @@ -1418,6 +1418,744 @@ 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} + release_package_artifact_name: release-package + 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 }} + 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 + [[ -x "$PREP_SCRIPT" ]] && "$PREP_SCRIPT" && prep_status=passed + [[ -x "$PREFLIGHT_SCRIPT" ]] && "$PREFLIGHT_SCRIPT" && preflight_status=passed + [[ -x "$BUILD_SCRIPT" ]] && "$BUILD_SCRIPT" && build_status=passed + 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" </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}")" + rm -rf "$ARTIFACT_DIR"; mkdir -p "$ARTIFACT_DIR" + gh run download "$PREFLIGHT_RUN_ID" --repo "$GITHUB_REPOSITORY" --name release-package --dir "$ARTIFACT_DIR" + [[ -f "$ARTIFACT_DIR/release-evidence.json" ]] || { echo "Missing preflight release-evidence.json." >&2; exit 1; } + gh run view "$VALIDATION_RUN_ID" --repo "$GITHUB_REPOSITORY" --json conclusion --jq '.conclusion' | grep -qx success + [[ -x "$PUBLISH_SCRIPT" ]] && "$PUBLISH_SCRIPT" + if [[ "$CREATE_GITHUB_RELEASE" == "true" ]]; then + 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" "$ARTIFACT_DIR"/* --repo "$GITHUB_REPOSITORY" --clobber \\ + || gh release create "$TAG" "$ARTIFACT_DIR"/* --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 + [[ -x "$POSTPUBLISH_SCRIPT" ]] && "$POSTPUBLISH_SCRIPT" "$TAG" || true + 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; } + [[ -x "$POSTPUBLISH_SCRIPT" ]] && "$POSTPUBLISH_SCRIPT" "$TAG" || true + 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 +2588,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 +2650,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 +2678,7 @@ export function renderManagedFiles(manifest: BootstrapManifest): RenderedFile[] } ] : []), - ...(manifest.release.enabled + ...(manifest.release.enabled && !releaseIsGoverned ? [ { path: ".github/workflows/release-tag.yml", @@ -1939,6 +2687,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 +2795,7 @@ export function renderManagedFiles(manifest: BootstrapManifest): RenderedFile[] } ] : []), + ...(releaseIsGoverned ? governedReleaseHookScripts() : []), { path: "scripts/codex-cloud/setup.sh", reason: "Codex cloud setup script", @@ -2034,6 +2827,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/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/render.test.ts b/tests/render.test.ts index 77b5652..19789b1 100644 --- a/tests/render.test.ts +++ b/tests/render.test.ts @@ -282,6 +282,51 @@ 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("UPDATE_MAJOR_TAG"); + 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..d70ff81 100644 --- a/tests/reusable-workflows.test.ts +++ b/tests/reusable-workflows.test.ts @@ -83,7 +83,27 @@ 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((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((publish.on as any).workflow_call.inputs.require_release_issue.default).toBe(true); + expect((publish.jobs as any).publish.environment).toBe( + "${{ inputs.publish_environment || 'release-publish' }}" + ); + expect(postpublish.name).toBe("Reusable Release Postpublish"); + expect((postpublish.jobs as any).postpublish).toBeTruthy(); }); }); From c7a86e10f6155acbec6d0d3a0e73b33c3cab2dbb Mon Sep 17 00:00:00 2001 From: Hephaestus Date: Mon, 1 Jun 2026 14:06:15 +0000 Subject: [PATCH 02/29] Fix governed release workflow contracts --- .github/workflows/full-release-validation-reusable.yml | 1 + .github/workflows/release-publish-reusable.yml | 1 + package-lock.json | 9 +++------ src/archetypes.ts | 8 +++++++- tests/reusable-workflows.test.ts | 2 ++ 5 files changed, 14 insertions(+), 7 deletions(-) diff --git a/.github/workflows/full-release-validation-reusable.yml b/.github/workflows/full-release-validation-reusable.yml index 4a21365..87b1301 100644 --- a/.github/workflows/full-release-validation-reusable.yml +++ b/.github/workflows/full-release-validation-reusable.yml @@ -8,6 +8,7 @@ on: 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 } + release_package_artifact_name: { required: false, type: string, default: release-package } evidence_artifact_name: { required: false, type: string, default: release-evidence } evidence_retention_days: { required: false, type: number, default: 365 } diff --git a/.github/workflows/release-publish-reusable.yml b/.github/workflows/release-publish-reusable.yml index 79ccf83..a035ed1 100644 --- a/.github/workflows/release-publish-reusable.yml +++ b/.github/workflows/release-publish-reusable.yml @@ -16,6 +16,7 @@ on: update_major_tag: { required: false, type: boolean, default: true } update_minor_tag: { required: false, type: boolean, default: true } tag_prefix: { required: false, type: string, default: v } + default_branch: { required: false, type: string, default: main } release_issue: { required: false, type: string, default: "" } require_release_issue: { required: false, type: boolean, default: true } require_signed_tag: { required: false, type: boolean, default: false } 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/src/archetypes.ts b/src/archetypes.ts index c936cd3..a80d8ff 100644 --- a/src/archetypes.ts +++ b/src/archetypes.ts @@ -1691,6 +1691,7 @@ function fullReleaseValidationReusableWorkflow(): 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 } + release_package_artifact_name: { required: false, type: string, default: release-package } evidence_artifact_name: { required: false, type: string, default: release-evidence } evidence_retention_days: { required: false, type: number, default: 365 } @@ -1731,7 +1732,7 @@ function fullReleaseValidationReusableWorkflow(): string { JSON - uses: actions/upload-artifact@v4 with: - name: \${{ inputs.evidence_artifact_name }}-validation + name: \${{ inputs.release_package_artifact_name }}-validation path: \${{ inputs.artifact_dir }}/validation-evidence.json retention-days: \${{ inputs.evidence_retention_days }} `; @@ -1757,6 +1758,7 @@ function releasePublishReusableWorkflow(): string { update_major_tag: { required: false, type: boolean, default: true } update_minor_tag: { required: false, type: boolean, default: true } tag_prefix: { required: false, type: string, default: v } + default_branch: { required: false, type: string, default: main } release_issue: { required: false, type: string, default: "" } require_release_issue: { required: false, type: boolean, default: true } require_signed_tag: { required: false, type: boolean, default: false } @@ -1808,6 +1810,10 @@ function releasePublishReusableWorkflow(): string { rm -rf "$ARTIFACT_DIR"; mkdir -p "$ARTIFACT_DIR" gh run download "$PREFLIGHT_RUN_ID" --repo "$GITHUB_REPOSITORY" --name release-package --dir "$ARTIFACT_DIR" [[ -f "$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)' "$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)' "$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; } gh run view "$VALIDATION_RUN_ID" --repo "$GITHUB_REPOSITORY" --json conclusion --jq '.conclusion' | grep -qx success [[ -x "$PUBLISH_SCRIPT" ]] && "$PUBLISH_SCRIPT" if [[ "$CREATE_GITHUB_RELEASE" == "true" ]]; then diff --git a/tests/reusable-workflows.test.ts b/tests/reusable-workflows.test.ts index d70ff81..46c44d2 100644 --- a/tests/reusable-workflows.test.ts +++ b/tests/reusable-workflows.test.ts @@ -95,11 +95,13 @@ describe("reusable workflows", () => { 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.release_package_artifact_name.default).toBe("release-package"); 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((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' }}" ); From 8a8f1ae3102f5a5bf5f85238e335f5c059d15347 Mon Sep 17 00:00:00 2001 From: Hephaestus Date: Mon, 1 Jun 2026 20:04:25 +0000 Subject: [PATCH 03/29] Enforce governed release evidence provenance --- .github/workflows/release-publish-reusable.yml | 13 +++++++++++++ src/archetypes.ts | 9 +++++++++ tests/render.test.ts | 3 +++ tests/reusable-workflows.test.ts | 3 +++ 4 files changed, 28 insertions(+) diff --git a/.github/workflows/release-publish-reusable.yml b/.github/workflows/release-publish-reusable.yml index a035ed1..4896afe 100644 --- a/.github/workflows/release-publish-reusable.yml +++ b/.github/workflows/release-publish-reusable.yml @@ -68,6 +68,19 @@ jobs: rm -rf "$ARTIFACT_DIR"; mkdir -p "$ARTIFACT_DIR" gh run download "$PREFLIGHT_RUN_ID" --repo "$GITHUB_REPOSITORY" --name release-package --dir "$ARTIFACT_DIR" [[ -f "$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)' "$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)' "$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; } + rm -rf "$ARTIFACT_DIR/validation" && mkdir -p "$ARTIFACT_DIR/validation" + gh run download "$VALIDATION_RUN_ID" --repo "$GITHUB_REPOSITORY" --name release-evidence-validation --dir "$ARTIFACT_DIR/validation" + [[ -f "$ARTIFACT_DIR/validation/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)' "$ARTIFACT_DIR/validation/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)' "$ARTIFACT_DIR/validation/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)' "$ARTIFACT_DIR/validation/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 [[ -x "$PUBLISH_SCRIPT" ]] && "$PUBLISH_SCRIPT" if [[ "$CREATE_GITHUB_RELEASE" == "true" ]]; then diff --git a/src/archetypes.ts b/src/archetypes.ts index a80d8ff..ba46925 100644 --- a/src/archetypes.ts +++ b/src/archetypes.ts @@ -1814,6 +1814,15 @@ function releasePublishReusableWorkflow(): string { [[ "$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)' "$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; } + rm -rf "$ARTIFACT_DIR/validation" && mkdir -p "$ARTIFACT_DIR/validation" + gh run download "$VALIDATION_RUN_ID" --repo "$GITHUB_REPOSITORY" --name release-evidence-validation --dir "$ARTIFACT_DIR/validation" + [[ -f "$ARTIFACT_DIR/validation/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)' "$ARTIFACT_DIR/validation/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)' "$ARTIFACT_DIR/validation/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)' "$ARTIFACT_DIR/validation/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 [[ -x "$PUBLISH_SCRIPT" ]] && "$PUBLISH_SCRIPT" if [[ "$CREATE_GITHUB_RELEASE" == "true" ]]; then diff --git a/tests/render.test.ts b/tests/render.test.ts index 19789b1..9d78ce2 100644 --- a/tests/render.test.ts +++ b/tests/render.test.ts @@ -323,6 +323,9 @@ describe("renderManagedFiles", () => { expect(publish?.contents).toContain("require_signed_tag: false"); expect(reusablePublish?.contents).toContain("gh run download"); expect(reusablePublish?.contents).toContain("UPDATE_MAJOR_TAG"); + 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("release-evidence-validation"); expect(releaseTrain?.contents).toContain("Publish must consume the artifact bundle proven by preflight"); expect(issueTemplate?.contents).toContain("preflight_run_id recorded"); }); diff --git a/tests/reusable-workflows.test.ts b/tests/reusable-workflows.test.ts index 46c44d2..0988602 100644 --- a/tests/reusable-workflows.test.ts +++ b/tests/reusable-workflows.test.ts @@ -105,6 +105,9 @@ describe("reusable workflows", () => { expect((publish.jobs as any).publish.environment).toBe( "${{ inputs.publish_environment || 'release-publish' }}" ); + 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(postpublish.name).toBe("Reusable Release Postpublish"); expect((postpublish.jobs as any).postpublish).toBeTruthy(); }); From ce9c6d34e27dbec2fee63cf0d781054c2d855580 Mon Sep 17 00:00:00 2001 From: Hephaestus Date: Mon, 1 Jun 2026 21:04:17 +0000 Subject: [PATCH 04/29] Enforce governed release publish controls --- .github/workflows/release-publish-reusable.yml | 15 ++++++++++++++- src/archetypes.ts | 12 +++++++++++- 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release-publish-reusable.yml b/.github/workflows/release-publish-reusable.yml index 4896afe..5ea05d9 100644 --- a/.github/workflows/release-publish-reusable.yml +++ b/.github/workflows/release-publish-reusable.yml @@ -53,6 +53,7 @@ jobs: RELEASE_ISSUE: ${{ inputs.release_issue }} REQUIRE_RELEASE_ISSUE: ${{ inputs.require_release_issue }} REQUIRE_SIGNED_TAG: ${{ inputs.require_signed_tag }} + REQUIRE_POSTPUBLISH_VERIFICATION: ${{ inputs.require_postpublish_verification }} CREATE_GITHUB_RELEASE: ${{ inputs.create_github_release }} UPDATE_MAJOR_TAG: ${{ inputs.update_major_tag }} UPDATE_MINOR_TAG: ${{ inputs.update_minor_tag }} @@ -65,6 +66,9 @@ jobs: set -euo pipefail [[ "$REQUIRE_RELEASE_ISSUE" != "true" || -n "$RELEASE_ISSUE" ]] || { echo "release_issue is required." >&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" gh run download "$PREFLIGHT_RUN_ID" --repo "$GITHUB_REPOSITORY" --name release-package --dir "$ARTIFACT_DIR" [[ -f "$ARTIFACT_DIR/release-evidence.json" ]] || { echo "Missing preflight release-evidence.json." >&2; exit 1; } @@ -97,7 +101,16 @@ jobs: [[ "$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 - [[ -x "$POSTPUBLISH_SCRIPT" ]] && "$POSTPUBLISH_SCRIPT" "$TAG" || true + if [[ -x "$POSTPUBLISH_SCRIPT" ]]; then + if [[ "$REQUIRE_POSTPUBLISH_VERIFICATION" == "true" ]]; then + "$POSTPUBLISH_SCRIPT" "$TAG" + else + "$POSTPUBLISH_SCRIPT" "$TAG" || true + fi + 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: diff --git a/src/archetypes.ts b/src/archetypes.ts index ba46925..810d2a8 100644 --- a/src/archetypes.ts +++ b/src/archetypes.ts @@ -1795,6 +1795,7 @@ function releasePublishReusableWorkflow(): string { RELEASE_ISSUE: \${{ inputs.release_issue }} REQUIRE_RELEASE_ISSUE: \${{ inputs.require_release_issue }} REQUIRE_SIGNED_TAG: \${{ inputs.require_signed_tag }} + REQUIRE_POSTPUBLISH_VERIFICATION: \${{ inputs.require_postpublish_verification }} CREATE_GITHUB_RELEASE: \${{ inputs.create_github_release }} UPDATE_MAJOR_TAG: \${{ inputs.update_major_tag }} UPDATE_MINOR_TAG: \${{ inputs.update_minor_tag }} @@ -1807,6 +1808,9 @@ function releasePublishReusableWorkflow(): string { set -euo pipefail [[ "$REQUIRE_RELEASE_ISSUE" != "true" || -n "$RELEASE_ISSUE" ]] || { echo "release_issue is required." >&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" gh run download "$PREFLIGHT_RUN_ID" --repo "$GITHUB_REPOSITORY" --name release-package --dir "$ARTIFACT_DIR" [[ -f "$ARTIFACT_DIR/release-evidence.json" ]] || { echo "Missing preflight release-evidence.json." >&2; exit 1; } @@ -1839,7 +1843,13 @@ function releasePublishReusableWorkflow(): string { [[ "$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 - [[ -x "$POSTPUBLISH_SCRIPT" ]] && "$POSTPUBLISH_SCRIPT" "$TAG" || true + if [[ -x "$POSTPUBLISH_SCRIPT" ]]; then + if [[ "$REQUIRE_POSTPUBLISH_VERIFICATION" == "true" ]]; then + "$POSTPUBLISH_SCRIPT" "$TAG" + else + "$POSTPUBLISH_SCRIPT" "$TAG" || true + fi + 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: From f34107b39789a8f26220ddeaae89ca4d28ebed46 Mon Sep 17 00:00:00 2001 From: Hephaestus Date: Tue, 2 Jun 2026 00:03:03 +0000 Subject: [PATCH 05/29] Tighten governed release publish checks --- src/archetypes.ts | 3 +++ tests/render.test.ts | 1 + 2 files changed, 4 insertions(+) diff --git a/src/archetypes.ts b/src/archetypes.ts index 810d2a8..4a92442 100644 --- a/src/archetypes.ts +++ b/src/archetypes.ts @@ -1849,6 +1849,9 @@ function releasePublishReusableWorkflow(): string { else "$POSTPUBLISH_SCRIPT" "$TAG" || true fi + 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 diff --git a/tests/render.test.ts b/tests/render.test.ts index 9d78ce2..5a1c16d 100644 --- a/tests/render.test.ts +++ b/tests/render.test.ts @@ -326,6 +326,7 @@ describe("renderManagedFiles", () => { 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("release-evidence-validation"); + 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"); }); From 8a515159a119207adb8ac95d2b753e9bcfecd789 Mon Sep 17 00:00:00 2001 From: Hephaestus Date: Tue, 2 Jun 2026 02:34:51 +0000 Subject: [PATCH 06/29] Fail closed on postpublish verification --- .../release-postpublish-reusable.yml | 7 +++++- src/archetypes.ts | 7 +++++- tests/release-postpublish.test.ts | 23 +++++++++++++++++++ 3 files changed, 35 insertions(+), 2 deletions(-) create mode 100644 tests/release-postpublish.test.ts diff --git a/.github/workflows/release-postpublish-reusable.yml b/.github/workflows/release-postpublish-reusable.yml index 18c87cf..b2f70a8 100644 --- a/.github/workflows/release-postpublish-reusable.yml +++ b/.github/workflows/release-postpublish-reusable.yml @@ -39,7 +39,12 @@ jobs: 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; } - [[ -x "$POSTPUBLISH_SCRIPT" ]] && "$POSTPUBLISH_SCRIPT" "$TAG" || true + 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: diff --git a/src/archetypes.ts b/src/archetypes.ts index 4a92442..0b04f6e 100644 --- a/src/archetypes.ts +++ b/src/archetypes.ts @@ -1904,7 +1904,12 @@ function releasePostpublishReusableWorkflow(): string { 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; } - [[ -x "$POSTPUBLISH_SCRIPT" ]] && "$POSTPUBLISH_SCRIPT" "$TAG" || true + 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: 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'); + }); +}); From a270c2cfe559382135340af1e20f7d6f20a448d2 Mon Sep 17 00:00:00 2001 From: Hephaestus Date: Tue, 2 Jun 2026 03:04:09 +0000 Subject: [PATCH 07/29] Fail closed on postpublish verification --- .github/workflows/release-publish-reusable.yml | 6 +----- src/archetypes.ts | 6 +----- tests/render.test.ts | 1 + tests/reusable-workflows.test.ts | 3 +++ 4 files changed, 6 insertions(+), 10 deletions(-) diff --git a/.github/workflows/release-publish-reusable.yml b/.github/workflows/release-publish-reusable.yml index 5ea05d9..11f875c 100644 --- a/.github/workflows/release-publish-reusable.yml +++ b/.github/workflows/release-publish-reusable.yml @@ -102,11 +102,7 @@ jobs: [[ "$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 - if [[ "$REQUIRE_POSTPUBLISH_VERIFICATION" == "true" ]]; then - "$POSTPUBLISH_SCRIPT" "$TAG" - else - "$POSTPUBLISH_SCRIPT" "$TAG" || true - fi + "$POSTPUBLISH_SCRIPT" "$TAG" elif [[ "$REQUIRE_POSTPUBLISH_VERIFICATION" == "true" ]]; then echo "Postpublish verification script is required but missing or not executable." >&2 exit 1 diff --git a/src/archetypes.ts b/src/archetypes.ts index 0b04f6e..aaa52ac 100644 --- a/src/archetypes.ts +++ b/src/archetypes.ts @@ -1844,11 +1844,7 @@ function releasePublishReusableWorkflow(): string { [[ "$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 - if [[ "$REQUIRE_POSTPUBLISH_VERIFICATION" == "true" ]]; then - "$POSTPUBLISH_SCRIPT" "$TAG" - else - "$POSTPUBLISH_SCRIPT" "$TAG" || true - fi + "$POSTPUBLISH_SCRIPT" "$TAG" elif [[ "$REQUIRE_POSTPUBLISH_VERIFICATION" == "true" ]]; then echo "Postpublish verification script is required but missing or not executable." >&2 exit 1 diff --git a/tests/render.test.ts b/tests/render.test.ts index 5a1c16d..4982462 100644 --- a/tests/render.test.ts +++ b/tests/render.test.ts @@ -326,6 +326,7 @@ describe("renderManagedFiles", () => { 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("release-evidence-validation"); + 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"); diff --git a/tests/reusable-workflows.test.ts b/tests/reusable-workflows.test.ts index 0988602..4ff5a50 100644 --- a/tests/reusable-workflows.test.ts +++ b/tests/reusable-workflows.test.ts @@ -100,6 +100,7 @@ describe("reusable workflows", () => { 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( @@ -110,5 +111,7 @@ describe("reusable workflows", () => { expect((publish.jobs as any).publish.steps[1].run).toContain("release-evidence-validation"); 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"); }); }); From ecfd51e25a86270c375874e8a2a0ff0785bfa6b1 Mon Sep 17 00:00:00 2001 From: Hephaestus Date: Tue, 2 Jun 2026 03:33:17 +0000 Subject: [PATCH 08/29] Fix release validation artifact naming --- src/archetypes.ts | 2 +- tests/render.test.ts | 3 +++ tests/reusable-workflows.test.ts | 1 + 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/archetypes.ts b/src/archetypes.ts index aaa52ac..40621d4 100644 --- a/src/archetypes.ts +++ b/src/archetypes.ts @@ -1732,7 +1732,7 @@ function fullReleaseValidationReusableWorkflow(): string { JSON - uses: actions/upload-artifact@v4 with: - name: \${{ inputs.release_package_artifact_name }}-validation + name: \${{ inputs.evidence_artifact_name }}-validation path: \${{ inputs.artifact_dir }}/validation-evidence.json retention-days: \${{ inputs.evidence_retention_days }} `; diff --git a/tests/render.test.ts b/tests/render.test.ts index 4982462..aa364d5 100644 --- a/tests/render.test.ts +++ b/tests/render.test.ts @@ -326,6 +326,9 @@ describe("renderManagedFiles", () => { 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("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"); diff --git a/tests/reusable-workflows.test.ts b/tests/reusable-workflows.test.ts index 4ff5a50..506f246 100644 --- a/tests/reusable-workflows.test.ts +++ b/tests/reusable-workflows.test.ts @@ -96,6 +96,7 @@ describe("reusable workflows", () => { 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.release_package_artifact_name.default).toBe("release-package"); + 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(); From 6387bf7b6809486b72134f0581d2f7691fce970b Mon Sep 17 00:00:00 2001 From: Hephaestus Date: Tue, 2 Jun 2026 04:32:53 +0000 Subject: [PATCH 09/29] Tighten release validation artifact contract --- .github/workflows/full-release-validation-reusable.yml | 1 - .github/workflows/full-release-validation.yml | 1 - src/archetypes.ts | 2 -- tests/reusable-workflows.test.ts | 3 ++- 4 files changed, 2 insertions(+), 5 deletions(-) diff --git a/.github/workflows/full-release-validation-reusable.yml b/.github/workflows/full-release-validation-reusable.yml index 87b1301..4a21365 100644 --- a/.github/workflows/full-release-validation-reusable.yml +++ b/.github/workflows/full-release-validation-reusable.yml @@ -8,7 +8,6 @@ on: 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 } - release_package_artifact_name: { required: false, type: string, default: release-package } evidence_artifact_name: { required: false, type: string, default: release-evidence } evidence_retention_days: { required: false, type: number, default: 365 } diff --git a/.github/workflows/full-release-validation.yml b/.github/workflows/full-release-validation.yml index 0a20074..c1daa0a 100644 --- a/.github/workflows/full-release-validation.yml +++ b/.github/workflows/full-release-validation.yml @@ -22,6 +22,5 @@ jobs: release_profile: ${{ inputs.release_profile }} validate_script: scripts/release/validate.sh artifact_dir: dist/release - release_package_artifact_name: release-package evidence_artifact_name: release-evidence evidence_retention_days: 365 diff --git a/src/archetypes.ts b/src/archetypes.ts index 40621d4..7990555 100644 --- a/src/archetypes.ts +++ b/src/archetypes.ts @@ -1494,7 +1494,6 @@ function fullReleaseValidationCallerWorkflow(manifest: BootstrapManifest): strin release_profile: \${{ inputs.release_profile }} validate_script: scripts/release/validate.sh artifact_dir: ${manifest.release.artifacts.directory} - release_package_artifact_name: release-package evidence_artifact_name: release-evidence evidence_retention_days: 365 `; @@ -1691,7 +1690,6 @@ function fullReleaseValidationReusableWorkflow(): 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 } - release_package_artifact_name: { required: false, type: string, default: release-package } evidence_artifact_name: { required: false, type: string, default: release-evidence } evidence_retention_days: { required: false, type: number, default: 365 } diff --git a/tests/reusable-workflows.test.ts b/tests/reusable-workflows.test.ts index 506f246..8d97aff 100644 --- a/tests/reusable-workflows.test.ts +++ b/tests/reusable-workflows.test.ts @@ -95,7 +95,8 @@ describe("reusable workflows", () => { 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.release_package_artifact_name.default).toBe("release-package"); + 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"); From ace1460c033dc29fe2c30df52d114fc264c58d47 Mon Sep 17 00:00:00 2001 From: Hephaestus Date: Tue, 2 Jun 2026 05:04:47 +0000 Subject: [PATCH 10/29] test: cover governed release provenance checks --- tests/reusable-workflows.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/reusable-workflows.test.ts b/tests/reusable-workflows.test.ts index 8d97aff..0562b42 100644 --- a/tests/reusable-workflows.test.ts +++ b/tests/reusable-workflows.test.ts @@ -111,6 +111,8 @@ describe("reusable workflows", () => { 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("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"); From c36504fe49a1296709a50bc64c45a8481e10df04 Mon Sep 17 00:00:00 2001 From: Hephaestus Date: Tue, 2 Jun 2026 05:33:41 +0000 Subject: [PATCH 11/29] Fail closed on release hook execution --- .../full-release-validation-reusable.yml | 5 ++- .../workflows/release-preflight-reusable.yml | 15 ++++++-- src/archetypes.ts | 20 ++++++++-- test/release-workflow-guards.test.ts | 37 +++++++++++++++++++ 4 files changed, 69 insertions(+), 8 deletions(-) create mode 100644 test/release-workflow-guards.test.ts diff --git a/.github/workflows/full-release-validation-reusable.yml b/.github/workflows/full-release-validation-reusable.yml index 4a21365..c83d79d 100644 --- a/.github/workflows/full-release-validation-reusable.yml +++ b/.github/workflows/full-release-validation-reusable.yml @@ -37,7 +37,10 @@ jobs: mkdir -p "$ARTIFACT_DIR" validate_status=skipped standard_status=skipped - [[ -x "$VALIDATE_SCRIPT" ]] && "$VALIDATE_SCRIPT" && validate_status=passed + 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 diff --git a/.github/workflows/release-preflight-reusable.yml b/.github/workflows/release-preflight-reusable.yml index 850df42..303ba80 100644 --- a/.github/workflows/release-preflight-reusable.yml +++ b/.github/workflows/release-preflight-reusable.yml @@ -55,9 +55,18 @@ jobs: [[ "$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 - [[ -x "$PREP_SCRIPT" ]] && "$PREP_SCRIPT" && prep_status=passed - [[ -x "$PREFLIGHT_SCRIPT" ]] && "$PREFLIGHT_SCRIPT" && preflight_status=passed - [[ -x "$BUILD_SCRIPT" ]] && "$BUILD_SCRIPT" && build_status=passed + 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" diff --git a/src/archetypes.ts b/src/archetypes.ts index 7990555..974c503 100644 --- a/src/archetypes.ts +++ b/src/archetypes.ts @@ -1650,9 +1650,18 @@ function releasePreflightReusableWorkflow(): string { [[ "$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 - [[ -x "$PREP_SCRIPT" ]] && "$PREP_SCRIPT" && prep_status=passed - [[ -x "$PREFLIGHT_SCRIPT" ]] && "$PREFLIGHT_SCRIPT" && preflight_status=passed - [[ -x "$BUILD_SCRIPT" ]] && "$BUILD_SCRIPT" && build_status=passed + 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" @@ -1719,7 +1728,10 @@ function fullReleaseValidationReusableWorkflow(): string { mkdir -p "$ARTIFACT_DIR" validate_status=skipped standard_status=skipped - [[ -x "$VALIDATE_SCRIPT" ]] && "$VALIDATE_SCRIPT" && validate_status=passed + 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 diff --git a/test/release-workflow-guards.test.ts b/test/release-workflow-guards.test.ts new file mode 100644 index 0000000..90a8eb1 --- /dev/null +++ b/test/release-workflow-guards.test.ts @@ -0,0 +1,37 @@ +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'); + }); +}); From 48af0dd99f3fab9ab2960cbf245093439951a710 Mon Sep 17 00:00:00 2001 From: Hephaestus Date: Tue, 2 Jun 2026 06:02:57 +0000 Subject: [PATCH 12/29] Fix governed release hook execution --- .github/workflows/release-publish-reusable.yml | 4 +++- src/archetypes.ts | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release-publish-reusable.yml b/.github/workflows/release-publish-reusable.yml index 11f875c..4ab55a0 100644 --- a/.github/workflows/release-publish-reusable.yml +++ b/.github/workflows/release-publish-reusable.yml @@ -86,7 +86,9 @@ jobs: 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)' "$ARTIFACT_DIR/validation/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 - [[ -x "$PUBLISH_SCRIPT" ]] && "$PUBLISH_SCRIPT" + if [[ -x "$PUBLISH_SCRIPT" ]]; then + "$PUBLISH_SCRIPT" + fi if [[ "$CREATE_GITHUB_RELEASE" == "true" ]]; then release_args=() [[ "$TAG" == *"-rc."* || "$TAG" == *"-beta."* ]] && release_args+=(--prerelease --latest=false) diff --git a/src/archetypes.ts b/src/archetypes.ts index 974c503..89437df 100644 --- a/src/archetypes.ts +++ b/src/archetypes.ts @@ -1838,7 +1838,9 @@ function releasePublishReusableWorkflow(): string { 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)' "$ARTIFACT_DIR/validation/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 - [[ -x "$PUBLISH_SCRIPT" ]] && "$PUBLISH_SCRIPT" + if [[ -x "$PUBLISH_SCRIPT" ]]; then + "$PUBLISH_SCRIPT" + fi if [[ "$CREATE_GITHUB_RELEASE" == "true" ]]; then release_args=() [[ "$TAG" == *"-rc."* || "$TAG" == *"-beta."* ]] && release_args+=(--prerelease --latest=false) From 770b445f13ab0d888ff6f35dedba46c8564a1d39 Mon Sep 17 00:00:00 2001 From: Hephaestus Date: Tue, 2 Jun 2026 07:34:14 +0000 Subject: [PATCH 13/29] test: cover release publish provenance checks --- test/release-workflow-guards.test.ts | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/test/release-workflow-guards.test.ts b/test/release-workflow-guards.test.ts index 90a8eb1..05945f2 100644 --- a/test/release-workflow-guards.test.ts +++ b/test/release-workflow-guards.test.ts @@ -34,4 +34,25 @@ describe('governed release hook guards', () => { expect(archetypes).not.toContain('[[ -x "$BUILD_SCRIPT" ]] && "$BUILD_SCRIPT" && build_status=passed'); expect(archetypes).not.toContain('[[ -x "$VALIDATE_SCRIPT" ]] && "$VALIDATE_SCRIPT" && validate_status=passed'); }); + + it('binds publish provenance to the tag sha and requested run ids', () => { + const workflow = read('.github/workflows/release-publish-reusable.yml'); + expect(workflow).toContain('gh run download "$PREFLIGHT_RUN_ID" --repo "$GITHUB_REPOSITORY" --name release-package --dir "$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 "$ARTIFACT_DIR/validation"'); + 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"); + + const archetypes = read('src/archetypes.ts'); + expect(archetypes).toContain('gh run download "$PREFLIGHT_RUN_ID" --repo "$GITHUB_REPOSITORY" --name release-package --dir "$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 run download "$VALIDATION_RUN_ID" --repo "$GITHUB_REPOSITORY" --name release-evidence-validation --dir "$ARTIFACT_DIR/validation"'); + 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; }'); + }); }); From 1693c31492450e21cc1d04e419c58391a3985431 Mon Sep 17 00:00:00 2001 From: Hephaestus Date: Tue, 2 Jun 2026 08:33:23 +0000 Subject: [PATCH 14/29] Fix release publish evidence isolation --- .github/workflows/release-publish-reusable.yml | 13 +++++++------ tests/reusable-workflows.test.ts | 6 ++++++ 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/.github/workflows/release-publish-reusable.yml b/.github/workflows/release-publish-reusable.yml index 4ab55a0..693811d 100644 --- a/.github/workflows/release-publish-reusable.yml +++ b/.github/workflows/release-publish-reusable.yml @@ -59,6 +59,7 @@ jobs: UPDATE_MINOR_TAG: ${{ inputs.update_minor_tag }} TAG_PREFIX: ${{ inputs.tag_prefix }} ARTIFACT_DIR: ${{ inputs.artifact_dir }} + VALIDATION_ARTIFACT_DIR: ${{ runner.temp }}/release-validation RELEASE_NOTES_FILE: ${{ inputs.release_notes_file }} PUBLISH_SCRIPT: ${{ inputs.publish_script }} POSTPUBLISH_SCRIPT: ${{ inputs.postpublish_script }} @@ -76,14 +77,14 @@ jobs: [[ "$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)' "$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; } - rm -rf "$ARTIFACT_DIR/validation" && mkdir -p "$ARTIFACT_DIR/validation" - gh run download "$VALIDATION_RUN_ID" --repo "$GITHUB_REPOSITORY" --name release-evidence-validation --dir "$ARTIFACT_DIR/validation" - [[ -f "$ARTIFACT_DIR/validation/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)' "$ARTIFACT_DIR/validation/validation-evidence.json")" + rm -rf "$VALIDATION_ARTIFACT_DIR" && mkdir -p "$VALIDATION_ARTIFACT_DIR" + 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)' "$ARTIFACT_DIR/validation/validation-evidence.json")" + 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)' "$ARTIFACT_DIR/validation/validation-evidence.json")" + 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 diff --git a/tests/reusable-workflows.test.ts b/tests/reusable-workflows.test.ts index 0562b42..4b09530 100644 --- a/tests/reusable-workflows.test.ts +++ b/tests/reusable-workflows.test.ts @@ -108,6 +108,12 @@ describe("reusable workflows", () => { expect((publish.jobs as any).publish.environment).toBe( "${{ inputs.publish_environment || 'release-publish' }}" ); + expect((publish.jobs as any).publish.steps[1].env.VALIDATION_ARTIFACT_DIR).toBe( + "${{ runner.temp }}/release-validation" + ); + 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"); From 33aa8c52fc2031bcfdcc5e6bdb5d79936651e28e Mon Sep 17 00:00:00 2001 From: Hephaestus Date: Tue, 2 Jun 2026 09:07:01 +0000 Subject: [PATCH 15/29] Fix release asset publishing scope --- .github/workflows/release-publish-reusable.yml | 5 +++-- src/archetypes.ts | 12 ++++++------ 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/.github/workflows/release-publish-reusable.yml b/.github/workflows/release-publish-reusable.yml index 693811d..c49afbc 100644 --- a/.github/workflows/release-publish-reusable.yml +++ b/.github/workflows/release-publish-reusable.yml @@ -91,11 +91,12 @@ jobs: "$PUBLISH_SCRIPT" fi if [[ "$CREATE_GITHUB_RELEASE" == "true" ]]; then + mapfile -t release_assets < <(find "$ARTIFACT_DIR" -maxdepth 1 -type f | sort) 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" "$ARTIFACT_DIR"/* --repo "$GITHUB_REPOSITORY" --clobber \ - || gh release create "$TAG" "$ARTIFACT_DIR"/* --repo "$GITHUB_REPOSITORY" --notes-file "$RELEASE_NOTES_FILE" "${release_args[@]}" + && 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" diff --git a/src/archetypes.ts b/src/archetypes.ts index 89437df..213cb5e 100644 --- a/src/archetypes.ts +++ b/src/archetypes.ts @@ -1828,14 +1828,14 @@ function releasePublishReusableWorkflow(): string { [[ "$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)' "$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; } - rm -rf "$ARTIFACT_DIR/validation" && mkdir -p "$ARTIFACT_DIR/validation" - gh run download "$VALIDATION_RUN_ID" --repo "$GITHUB_REPOSITORY" --name release-evidence-validation --dir "$ARTIFACT_DIR/validation" - [[ -f "$ARTIFACT_DIR/validation/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)' "$ARTIFACT_DIR/validation/validation-evidence.json")" + 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)' "$ARTIFACT_DIR/validation/validation-evidence.json")" + 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)' "$ARTIFACT_DIR/validation/validation-evidence.json")" + 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 From 176f7946168b9c12110f36fce5b810e2e00d1d0d Mon Sep 17 00:00:00 2001 From: Hephaestus Date: Tue, 2 Jun 2026 09:38:23 +0000 Subject: [PATCH 16/29] Fix governed release asset upload scope --- .github/workflows/release-publish-reusable.yml | 2 +- src/archetypes.ts | 7 +++++-- tests/render.test.ts | 6 ++++++ 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release-publish-reusable.yml b/.github/workflows/release-publish-reusable.yml index c49afbc..feeee29 100644 --- a/.github/workflows/release-publish-reusable.yml +++ b/.github/workflows/release-publish-reusable.yml @@ -77,7 +77,7 @@ jobs: [[ "$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)' "$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; } - rm -rf "$VALIDATION_ARTIFACT_DIR" && mkdir -p "$VALIDATION_ARTIFACT_DIR" + 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")" diff --git a/src/archetypes.ts b/src/archetypes.ts index 213cb5e..2fa376c 100644 --- a/src/archetypes.ts +++ b/src/archetypes.ts @@ -1637,6 +1637,7 @@ function releasePreflightReusableWorkflow(): string { 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 }} @@ -1811,6 +1812,7 @@ function releasePublishReusableWorkflow(): string { UPDATE_MINOR_TAG: \${{ inputs.update_minor_tag }} TAG_PREFIX: \${{ inputs.tag_prefix }} ARTIFACT_DIR: \${{ inputs.artifact_dir }} + VALIDATION_ARTIFACT_DIR: \${{ runner.temp }}/release-validation RELEASE_NOTES_FILE: \${{ inputs.release_notes_file }} PUBLISH_SCRIPT: \${{ inputs.publish_script }} POSTPUBLISH_SCRIPT: \${{ inputs.postpublish_script }} @@ -1842,11 +1844,12 @@ function releasePublishReusableWorkflow(): string { "$PUBLISH_SCRIPT" fi if [[ "$CREATE_GITHUB_RELEASE" == "true" ]]; then + mapfile -t release_assets < <(find "$ARTIFACT_DIR" -maxdepth 1 -type f | sort) 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" "$ARTIFACT_DIR"/* --repo "$GITHUB_REPOSITORY" --clobber \\ - || gh release create "$TAG" "$ARTIFACT_DIR"/* --repo "$GITHUB_REPOSITORY" --notes-file "$RELEASE_NOTES_FILE" "\${release_args[@]}" + && 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" diff --git a/tests/render.test.ts b/tests/render.test.ts index aa364d5..ad4cdff 100644 --- a/tests/render.test.ts +++ b/tests/render.test.ts @@ -322,9 +322,15 @@ describe("renderManagedFiles", () => { 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("VALIDATION_ARTIFACT_DIR: ${{ runner.temp }}/release-validation"); + expect(reusablePublish?.contents).toContain("mapfile -t release_assets < <(find \"$ARTIFACT_DIR\" -maxdepth 1 -type f | sort)"); 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"); From 2dad69c5f13ace55953dd67e990b6021ebfe13e6 Mon Sep 17 00:00:00 2001 From: Hephaestus Date: Tue, 2 Jun 2026 10:03:54 +0000 Subject: [PATCH 17/29] Fix governed release asset filtering --- .github/workflows/release-publish-reusable.yml | 2 +- src/archetypes.ts | 2 +- tests/reusable-workflows.test.ts | 3 +++ 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release-publish-reusable.yml b/.github/workflows/release-publish-reusable.yml index feeee29..8c1ea4e 100644 --- a/.github/workflows/release-publish-reusable.yml +++ b/.github/workflows/release-publish-reusable.yml @@ -91,7 +91,7 @@ jobs: "$PUBLISH_SCRIPT" fi if [[ "$CREATE_GITHUB_RELEASE" == "true" ]]; then - mapfile -t release_assets < <(find "$ARTIFACT_DIR" -maxdepth 1 -type f | sort) + mapfile -t release_assets < <(find "$ARTIFACT_DIR" -maxdepth 1 -type f \( ! -name release-evidence.json ! -name validation-evidence.json \) | sort) release_args=() [[ "$TAG" == *"-rc."* || "$TAG" == *"-beta."* ]] && release_args+=(--prerelease --latest=false) gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1 \ diff --git a/src/archetypes.ts b/src/archetypes.ts index 2fa376c..c94b76b 100644 --- a/src/archetypes.ts +++ b/src/archetypes.ts @@ -1844,7 +1844,7 @@ function releasePublishReusableWorkflow(): string { "$PUBLISH_SCRIPT" fi if [[ "$CREATE_GITHUB_RELEASE" == "true" ]]; then - mapfile -t release_assets < <(find "$ARTIFACT_DIR" -maxdepth 1 -type f | sort) + mapfile -t release_assets < <(find "$ARTIFACT_DIR" -maxdepth 1 -type f \( ! -name release-evidence.json ! -name validation-evidence.json \) | sort) release_args=() [[ "$TAG" == *"-rc."* || "$TAG" == *"-beta."* ]] && release_args+=(--prerelease --latest=false) gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1 \\ diff --git a/tests/reusable-workflows.test.ts b/tests/reusable-workflows.test.ts index 4b09530..0226698 100644 --- a/tests/reusable-workflows.test.ts +++ b/tests/reusable-workflows.test.ts @@ -117,6 +117,9 @@ describe("reusable workflows", () => { 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("validation-evidence.json"); + expect((publish.jobs as any).publish.steps[1].run).toContain("! -name release-evidence.json ! -name validation-evidence.json"); 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"); From 2b294b3b6e3fcafde8f51848971fe9f2c2198a3f Mon Sep 17 00:00:00 2001 From: Hephaestus Date: Tue, 2 Jun 2026 10:38:20 +0000 Subject: [PATCH 18/29] Fix governed release asset selection --- src/archetypes.ts | 2 +- test/release-workflow-guards.test.ts | 4 ++-- tests/render.test.ts | 4 +++- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/archetypes.ts b/src/archetypes.ts index c94b76b..84a1c74 100644 --- a/src/archetypes.ts +++ b/src/archetypes.ts @@ -1667,7 +1667,7 @@ function releasePreflightReusableWorkflow(): string { [[ -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" <"$RELEASE_ASSET_DIR/release-evidence.json" < { expect(workflow).toContain('gh run download "$PREFLIGHT_RUN_ID" --repo "$GITHUB_REPOSITORY" --name release-package --dir "$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 "$ARTIFACT_DIR/validation"'); + expect(workflow).toContain('gh run download "$VALIDATION_RUN_ID" --repo "$GITHUB_REPOSITORY" --name release-evidence-validation --dir "$VALIDATION_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; }'); @@ -50,7 +50,7 @@ describe('governed release hook guards', () => { expect(archetypes).toContain('gh run download "$PREFLIGHT_RUN_ID" --repo "$GITHUB_REPOSITORY" --name release-package --dir "$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 run download "$VALIDATION_RUN_ID" --repo "$GITHUB_REPOSITORY" --name release-evidence-validation --dir "$ARTIFACT_DIR/validation"'); + expect(archetypes).toContain('gh run download "$VALIDATION_RUN_ID" --repo "$GITHUB_REPOSITORY" --name release-evidence-validation --dir "$VALIDATION_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; }'); diff --git a/tests/render.test.ts b/tests/render.test.ts index ad4cdff..fd714f0 100644 --- a/tests/render.test.ts +++ b/tests/render.test.ts @@ -323,7 +323,9 @@ describe("renderManagedFiles", () => { expect(publish?.contents).toContain("require_signed_tag: false"); expect(reusablePublish?.contents).toContain("gh run download"); expect(reusablePublish?.contents).toContain("VALIDATION_ARTIFACT_DIR: ${{ runner.temp }}/release-validation"); - expect(reusablePublish?.contents).toContain("mapfile -t release_assets < <(find \"$ARTIFACT_DIR\" -maxdepth 1 -type f | sort)"); + expect(reusablePublish?.contents).toContain("release-evidence.json"); + expect(reusablePublish?.contents).toContain("validation-evidence.json"); + expect(reusablePublish?.contents).toContain("! -name release-evidence.json ! -name validation-evidence.json"); expect(reusablePublish?.contents).toContain("UPDATE_MAJOR_TAG"); expect(reusablePublish?.contents).toContain( "Preflight evidence run ID does not match the requested preflight run." From e79408ba7b6807971b0eb8c349ca9e6c40fec347 Mon Sep 17 00:00:00 2001 From: Hephaestus Date: Tue, 2 Jun 2026 11:05:45 +0000 Subject: [PATCH 19/29] Harden release publish asset staging --- .github/workflows/release-publish-reusable.yml | 13 ++++++++----- src/archetypes.ts | 17 ++++++++++------- tests/render.test.ts | 4 ++++ tests/reusable-workflows.test.ts | 4 ++++ 4 files changed, 26 insertions(+), 12 deletions(-) diff --git a/.github/workflows/release-publish-reusable.yml b/.github/workflows/release-publish-reusable.yml index 8c1ea4e..3ea70ed 100644 --- a/.github/workflows/release-publish-reusable.yml +++ b/.github/workflows/release-publish-reusable.yml @@ -71,11 +71,12 @@ jobs: git tag -v "$TAG" >/dev/null fi rm -rf "$ARTIFACT_DIR"; mkdir -p "$ARTIFACT_DIR" - gh run download "$PREFLIGHT_RUN_ID" --repo "$GITHUB_REPOSITORY" --name release-package --dir "$ARTIFACT_DIR" - [[ -f "$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)' "$ARTIFACT_DIR/release-evidence.json")" + 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)' "$ARTIFACT_DIR/release-evidence.json")" + 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" @@ -91,7 +92,9 @@ jobs: "$PUBLISH_SCRIPT" fi if [[ "$CREATE_GITHUB_RELEASE" == "true" ]]; then - mapfile -t release_assets < <(find "$ARTIFACT_DIR" -maxdepth 1 -type f \( ! -name release-evidence.json ! -name validation-evidence.json \) | sort) + RELEASE_ASSET_DIR="$(mktemp -d "${RUNNER_TEMP:-/tmp}/release-assets.XXXXXX")" + find "$PREFLIGHT_ARTIFACT_DIR" -maxdepth 1 -type f \( ! -name release-evidence.json ! -name validation-evidence.json \) -exec cp -p {} "$RELEASE_ASSET_DIR" \; + mapfile -t release_assets < <(find "$RELEASE_ASSET_DIR" -maxdepth 1 -type f | sort) release_args=() [[ "$TAG" == *"-rc."* || "$TAG" == *"-beta."* ]] && release_args+=(--prerelease --latest=false) gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1 \ diff --git a/src/archetypes.ts b/src/archetypes.ts index 84a1c74..9101247 100644 --- a/src/archetypes.ts +++ b/src/archetypes.ts @@ -1824,11 +1824,12 @@ function releasePublishReusableWorkflow(): string { git tag -v "$TAG" >/dev/null fi rm -rf "$ARTIFACT_DIR"; mkdir -p "$ARTIFACT_DIR" - gh run download "$PREFLIGHT_RUN_ID" --repo "$GITHUB_REPOSITORY" --name release-package --dir "$ARTIFACT_DIR" - [[ -f "$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)' "$ARTIFACT_DIR/release-evidence.json")" + 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)' "$ARTIFACT_DIR/release-evidence.json")" + 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" @@ -1844,11 +1845,13 @@ function releasePublishReusableWorkflow(): string { "$PUBLISH_SCRIPT" fi if [[ "$CREATE_GITHUB_RELEASE" == "true" ]]; then - mapfile -t release_assets < <(find "$ARTIFACT_DIR" -maxdepth 1 -type f \( ! -name release-evidence.json ! -name validation-evidence.json \) | sort) + RELEASE_ASSET_DIR="$(mktemp -d "\${RUNNER_TEMP:-/tmp}/release-assets.XXXXXX")" + find "$PREFLIGHT_ARTIFACT_DIR" -maxdepth 1 -type f \( ! -name release-evidence.json ! -name validation-evidence.json \) -exec cp -p {} "$RELEASE_ASSET_DIR" \; + mapfile -t release_assets < <(find "$RELEASE_ASSET_DIR" -maxdepth 1 -type f | sort) 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 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 diff --git a/tests/render.test.ts b/tests/render.test.ts index fd714f0..484e0e7 100644 --- a/tests/render.test.ts +++ b/tests/render.test.ts @@ -323,6 +323,10 @@ describe("renderManagedFiles", () => { expect(publish?.contents).toContain("require_signed_tag: false"); expect(reusablePublish?.contents).toContain("gh run download"); expect(reusablePublish?.contents).toContain("VALIDATION_ARTIFACT_DIR: ${{ runner.temp }}/release-validation"); + expect(reusablePublish?.contents).toContain("PREFLIGHT_ARTIFACT_DIR"); + expect(reusablePublish?.contents).toContain("RELEASE_ASSET_DIR"); + expect(reusablePublish?.contents).toContain('cp -p {} "$RELEASE_ASSET_DIR"'); + expect(reusablePublish?.contents).not.toContain('gh release upload "$TAG" "$ARTIFACT_DIR"/*'); expect(reusablePublish?.contents).toContain("release-evidence.json"); expect(reusablePublish?.contents).toContain("validation-evidence.json"); expect(reusablePublish?.contents).toContain("! -name release-evidence.json ! -name validation-evidence.json"); diff --git a/tests/reusable-workflows.test.ts b/tests/reusable-workflows.test.ts index 0226698..9b27c32 100644 --- a/tests/reusable-workflows.test.ts +++ b/tests/reusable-workflows.test.ts @@ -111,6 +111,10 @@ describe("reusable workflows", () => { expect((publish.jobs as any).publish.steps[1].env.VALIDATION_ARTIFACT_DIR).toBe( "${{ runner.temp }}/release-validation" ); + expect((publish.jobs as any).publish.steps[1].run).toContain("PREFLIGHT_ARTIFACT_DIR"); + expect((publish.jobs as any).publish.steps[1].run).toContain("RELEASE_ASSET_DIR"); + expect((publish.jobs as any).publish.steps[1].run).toContain('cp -p {} "$RELEASE_ASSET_DIR"'); + 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.' ); From 8c432eb16e8ba403001e593bea494147ceb41fa9 Mon Sep 17 00:00:00 2001 From: Hephaestus Date: Tue, 2 Jun 2026 11:36:47 +0000 Subject: [PATCH 20/29] Harden governed release asset staging --- .github/workflows/release-publish-reusable.yml | 9 +++++++-- src/archetypes.ts | 9 +++++++-- test/release-workflow-guards.test.ts | 6 ++++++ tests/render.test.ts | 4 +++- tests/reusable-workflows.test.ts | 5 ++++- 5 files changed, 27 insertions(+), 6 deletions(-) diff --git a/.github/workflows/release-publish-reusable.yml b/.github/workflows/release-publish-reusable.yml index 3ea70ed..947bae3 100644 --- a/.github/workflows/release-publish-reusable.yml +++ b/.github/workflows/release-publish-reusable.yml @@ -93,8 +93,13 @@ jobs: fi if [[ "$CREATE_GITHUB_RELEASE" == "true" ]]; then RELEASE_ASSET_DIR="$(mktemp -d "${RUNNER_TEMP:-/tmp}/release-assets.XXXXXX")" - find "$PREFLIGHT_ARTIFACT_DIR" -maxdepth 1 -type f \( ! -name release-evidence.json ! -name validation-evidence.json \) -exec cp -p {} "$RELEASE_ASSET_DIR" \; - mapfile -t release_assets < <(find "$RELEASE_ASSET_DIR" -maxdepth 1 -type f | sort) + release_assets=() + while IFS= read -r -d '' asset_path; do + asset_name="$(basename "$asset_path")" + cp -p -- "$asset_path" "$RELEASE_ASSET_DIR/$asset_name" + release_assets+=("$RELEASE_ASSET_DIR/$asset_name") + done < <(find "$PREFLIGHT_ARTIFACT_DIR" -maxdepth 1 -type f \( ! -name release-evidence.json ! -name validation-evidence.json \) -print0 | sort -z) + [[ ${#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 \ diff --git a/src/archetypes.ts b/src/archetypes.ts index 9101247..aab2990 100644 --- a/src/archetypes.ts +++ b/src/archetypes.ts @@ -1846,8 +1846,13 @@ function releasePublishReusableWorkflow(): string { fi if [[ "$CREATE_GITHUB_RELEASE" == "true" ]]; then RELEASE_ASSET_DIR="$(mktemp -d "\${RUNNER_TEMP:-/tmp}/release-assets.XXXXXX")" - find "$PREFLIGHT_ARTIFACT_DIR" -maxdepth 1 -type f \( ! -name release-evidence.json ! -name validation-evidence.json \) -exec cp -p {} "$RELEASE_ASSET_DIR" \; - mapfile -t release_assets < <(find "$RELEASE_ASSET_DIR" -maxdepth 1 -type f | sort) + release_assets=() + while IFS= read -r -d '' asset_path; do + asset_name="$(basename "$asset_path")" + cp -p -- "$asset_path" "$RELEASE_ASSET_DIR/$asset_name" + release_assets+=("$RELEASE_ASSET_DIR/$asset_name") + done < <(find "$PREFLIGHT_ARTIFACT_DIR" -maxdepth 1 -type f \( ! -name release-evidence.json ! -name validation-evidence.json \) -print0 | sort -z) + [[ \${#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 \ diff --git a/test/release-workflow-guards.test.ts b/test/release-workflow-guards.test.ts index 6bacb60..de412f8 100644 --- a/test/release-workflow-guards.test.ts +++ b/test/release-workflow-guards.test.ts @@ -45,6 +45,12 @@ describe('governed release hook guards', () => { 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(workflow).toContain('RELEASE_ASSET_DIR="$(mktemp -d "${RUNNER_TEMP:-/tmp}/release-assets.XXXXXX")"'); + expect(workflow).toContain("while IFS= read -r -d '' asset_path; do"); + expect(workflow).toContain('cp -p -- "$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" \;'); const archetypes = read('src/archetypes.ts'); expect(archetypes).toContain('gh run download "$PREFLIGHT_RUN_ID" --repo "$GITHUB_REPOSITORY" --name release-package --dir "$ARTIFACT_DIR"'); diff --git a/tests/render.test.ts b/tests/render.test.ts index 484e0e7..ce6b9dc 100644 --- a/tests/render.test.ts +++ b/tests/render.test.ts @@ -325,7 +325,9 @@ describe("renderManagedFiles", () => { expect(reusablePublish?.contents).toContain("VALIDATION_ARTIFACT_DIR: ${{ runner.temp }}/release-validation"); expect(reusablePublish?.contents).toContain("PREFLIGHT_ARTIFACT_DIR"); expect(reusablePublish?.contents).toContain("RELEASE_ASSET_DIR"); - expect(reusablePublish?.contents).toContain('cp -p {} "$RELEASE_ASSET_DIR"'); + expect(reusablePublish?.contents).toContain("while IFS= read -r -d '' asset_path; do"); + expect(reusablePublish?.contents).toContain('cp -p -- "$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).toContain("release-evidence.json"); expect(reusablePublish?.contents).toContain("validation-evidence.json"); diff --git a/tests/reusable-workflows.test.ts b/tests/reusable-workflows.test.ts index 9b27c32..7a6774a 100644 --- a/tests/reusable-workflows.test.ts +++ b/tests/reusable-workflows.test.ts @@ -113,7 +113,10 @@ describe("reusable workflows", () => { ); expect((publish.jobs as any).publish.steps[1].run).toContain("PREFLIGHT_ARTIFACT_DIR"); expect((publish.jobs as any).publish.steps[1].run).toContain("RELEASE_ASSET_DIR"); - expect((publish.jobs as any).publish.steps[1].run).toContain('cp -p {} "$RELEASE_ASSET_DIR"'); + expect((publish.jobs as any).publish.steps[1].run).toContain("while IFS= read -r -d '' asset_path; do"); + expect((publish.jobs as any).publish.steps[1].run).toContain('cp -p -- "$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.' From 9a263719189e99e5e179d1ab84e95e948e9f3b88 Mon Sep 17 00:00:00 2001 From: Hephaestus Date: Tue, 2 Jun 2026 12:02:46 +0000 Subject: [PATCH 21/29] Add release asset publish regression coverage --- tests/render.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/render.test.ts b/tests/render.test.ts index ce6b9dc..0c80651 100644 --- a/tests/render.test.ts +++ b/tests/render.test.ts @@ -329,6 +329,9 @@ describe("renderManagedFiles", () => { expect(reusablePublish?.contents).toContain('cp -p -- "$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('find "$PREFLIGHT_ARTIFACT_DIR" -maxdepth 1 -type f'); expect(reusablePublish?.contents).toContain("release-evidence.json"); expect(reusablePublish?.contents).toContain("validation-evidence.json"); expect(reusablePublish?.contents).toContain("! -name release-evidence.json ! -name validation-evidence.json"); From 4c777be2f5b16c5cbd8e74c0877e513f675706a7 Mon Sep 17 00:00:00 2001 From: Hephaestus Date: Tue, 2 Jun 2026 12:40:59 +0000 Subject: [PATCH 22/29] Harden governed release asset staging --- .github/workflows/release-publish-reusable.yml | 10 +++++++--- src/archetypes.ts | 14 +++++++++----- test/release-workflow-guards.test.ts | 6 ++++-- tests/render.test.ts | 12 ++++++------ tests/reusable-workflows.test.ts | 11 +++++++---- 5 files changed, 33 insertions(+), 20 deletions(-) diff --git a/.github/workflows/release-publish-reusable.yml b/.github/workflows/release-publish-reusable.yml index 947bae3..fca43f5 100644 --- a/.github/workflows/release-publish-reusable.yml +++ b/.github/workflows/release-publish-reusable.yml @@ -92,13 +92,17 @@ jobs: "$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_assets=() - while IFS= read -r -d '' asset_path; do + 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 -- "$asset_path" "$RELEASE_ASSET_DIR/$asset_name" + cp -p -- "$PREFLIGHT_ARTIFACT_DIR/$asset_path" "$RELEASE_ASSET_DIR/$asset_name" release_assets+=("$RELEASE_ASSET_DIR/$asset_name") - done < <(find "$PREFLIGHT_ARTIFACT_DIR" -maxdepth 1 -type f \( ! -name release-evidence.json ! -name validation-evidence.json \) -print0 | sort -z) + 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) diff --git a/src/archetypes.ts b/src/archetypes.ts index aab2990..5a049a9 100644 --- a/src/archetypes.ts +++ b/src/archetypes.ts @@ -1845,13 +1845,17 @@ function releasePublishReusableWorkflow(): string { "$PUBLISH_SCRIPT" fi if [[ "$CREATE_GITHUB_RELEASE" == "true" ]]; then - RELEASE_ASSET_DIR="$(mktemp -d "\${RUNNER_TEMP:-/tmp}/release-assets.XXXXXX")" + [[ -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_assets=() - while IFS= read -r -d '' asset_path; do - asset_name="$(basename "$asset_path")" - cp -p -- "$asset_path" "$RELEASE_ASSET_DIR/$asset_name" + 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 < <(find "$PREFLIGHT_ARTIFACT_DIR" -maxdepth 1 -type f \( ! -name release-evidence.json ! -name validation-evidence.json \) -print0 | sort -z) + 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) diff --git a/test/release-workflow-guards.test.ts b/test/release-workflow-guards.test.ts index de412f8..ce7083d 100644 --- a/test/release-workflow-guards.test.ts +++ b/test/release-workflow-guards.test.ts @@ -45,9 +45,11 @@ describe('governed release hook guards', () => { 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(workflow).toContain('[[ -f "$PREFLIGHT_ARTIFACT_DIR/SHA256SUMS" ]] || { echo "Missing preflight SHA256SUMS manifest." >&2; exit 1; }'); expect(workflow).toContain('RELEASE_ASSET_DIR="$(mktemp -d "${RUNNER_TEMP:-/tmp}/release-assets.XXXXXX")"'); - expect(workflow).toContain("while IFS= read -r -d '' asset_path; do"); - expect(workflow).toContain('cp -p -- "$asset_path" "$RELEASE_ASSET_DIR/$asset_name"'); + expect(workflow).toContain("while read -r asset_sha asset_path; do"); + expect(workflow).toContain('[[ "$asset_path" != *"release-evidence.json" && "$asset_path" != *"validation-evidence.json" ]] || continue'); + 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" \;'); diff --git a/tests/render.test.ts b/tests/render.test.ts index 0c80651..4e802f0 100644 --- a/tests/render.test.ts +++ b/tests/render.test.ts @@ -324,17 +324,17 @@ describe("renderManagedFiles", () => { expect(reusablePublish?.contents).toContain("gh run download"); expect(reusablePublish?.contents).toContain("VALIDATION_ARTIFACT_DIR: ${{ runner.temp }}/release-validation"); expect(reusablePublish?.contents).toContain("PREFLIGHT_ARTIFACT_DIR"); + expect(reusablePublish?.contents).toContain("SHA256SUMS"); expect(reusablePublish?.contents).toContain("RELEASE_ASSET_DIR"); - expect(reusablePublish?.contents).toContain("while IFS= read -r -d '' asset_path; do"); - expect(reusablePublish?.contents).toContain('cp -p -- "$asset_path" "$RELEASE_ASSET_DIR/$asset_name"'); + 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).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('find "$PREFLIGHT_ARTIFACT_DIR" -maxdepth 1 -type f'); - expect(reusablePublish?.contents).toContain("release-evidence.json"); - expect(reusablePublish?.contents).toContain("validation-evidence.json"); - expect(reusablePublish?.contents).toContain("! -name release-evidence.json ! -name validation-evidence.json"); + 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." diff --git a/tests/reusable-workflows.test.ts b/tests/reusable-workflows.test.ts index 7a6774a..ea7c139 100644 --- a/tests/reusable-workflows.test.ts +++ b/tests/reusable-workflows.test.ts @@ -112,9 +112,12 @@ describe("reusable workflows", () => { "${{ runner.temp }}/release-validation" ); 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 IFS= read -r -d '' asset_path; do"); - expect((publish.jobs as any).publish.steps[1].run).toContain('cp -p -- "$asset_path" "$RELEASE_ASSET_DIR/$asset_name"'); + 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"/*'); @@ -125,8 +128,8 @@ describe("reusable workflows", () => { 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("validation-evidence.json"); - expect((publish.jobs as any).publish.steps[1].run).toContain("! -name release-evidence.json ! -name validation-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"); From 6df1f0d36ed340fc08417c98083c60a42b6d8273 Mon Sep 17 00:00:00 2001 From: Hephaestus Date: Tue, 2 Jun 2026 13:04:05 +0000 Subject: [PATCH 23/29] Fix release provenance guard coverage --- test/release-workflow-guards.test.ts | 4 ++-- vitest.config.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/test/release-workflow-guards.test.ts b/test/release-workflow-guards.test.ts index ce7083d..3d7e133 100644 --- a/test/release-workflow-guards.test.ts +++ b/test/release-workflow-guards.test.ts @@ -37,7 +37,7 @@ describe('governed release hook guards', () => { it('binds publish provenance to the tag sha and requested run ids', () => { const workflow = read('.github/workflows/release-publish-reusable.yml'); - expect(workflow).toContain('gh run download "$PREFLIGHT_RUN_ID" --repo "$GITHUB_REPOSITORY" --name release-package --dir "$ARTIFACT_DIR"'); + 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"'); @@ -55,7 +55,7 @@ describe('governed release hook guards', () => { 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" \;'); const archetypes = read('src/archetypes.ts'); - expect(archetypes).toContain('gh run download "$PREFLIGHT_RUN_ID" --repo "$GITHUB_REPOSITORY" --name release-package --dir "$ARTIFACT_DIR"'); + 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 run download "$VALIDATION_RUN_ID" --repo "$GITHUB_REPOSITORY" --name release-evidence-validation --dir "$VALIDATION_ARTIFACT_DIR"'); 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"] } }); From c2ba431a8c41d90e3ed6ee4f400c8b2ddd4f6a92 Mon Sep 17 00:00:00 2001 From: Hephaestus Date: Tue, 2 Jun 2026 14:04:03 +0000 Subject: [PATCH 24/29] Tighten release publish asset guards --- test/release-workflow-guards.test.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/test/release-workflow-guards.test.ts b/test/release-workflow-guards.test.ts index 3d7e133..50c27ad 100644 --- a/test/release-workflow-guards.test.ts +++ b/test/release-workflow-guards.test.ts @@ -53,11 +53,17 @@ describe('governed release hook guards', () => { 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).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; }'); From e8da8d7932dea88dfd5101b633594f7ba46f3a4f Mon Sep 17 00:00:00 2001 From: Hephaestus Date: Tue, 2 Jun 2026 14:33:03 +0000 Subject: [PATCH 25/29] Harden release asset staging isolation --- .github/workflows/release-publish-reusable.yml | 1 + src/archetypes.ts | 1 + test/release-workflow-guards.test.ts | 1 + 3 files changed, 3 insertions(+) diff --git a/.github/workflows/release-publish-reusable.yml b/.github/workflows/release-publish-reusable.yml index fca43f5..db98aae 100644 --- a/.github/workflows/release-publish-reusable.yml +++ b/.github/workflows/release-publish-reusable.yml @@ -94,6 +94,7 @@ jobs: 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 diff --git a/src/archetypes.ts b/src/archetypes.ts index 5a049a9..57bba30 100644 --- a/src/archetypes.ts +++ b/src/archetypes.ts @@ -1847,6 +1847,7 @@ function releasePublishReusableWorkflow(): string { 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 diff --git a/test/release-workflow-guards.test.ts b/test/release-workflow-guards.test.ts index 50c27ad..846d039 100644 --- a/test/release-workflow-guards.test.ts +++ b/test/release-workflow-guards.test.ts @@ -47,6 +47,7 @@ describe('governed release hook guards', () => { expect(workflow).toContain("gh run view \"$VALIDATION_RUN_ID\" --repo \"$GITHUB_REPOSITORY\" --json conclusion --jq '.conclusion' | grep -qx success"); expect(workflow).toContain('[[ -f "$PREFLIGHT_ARTIFACT_DIR/SHA256SUMS" ]] || { echo "Missing preflight SHA256SUMS manifest." >&2; exit 1; }'); 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('[[ "$asset_path" != *"release-evidence.json" && "$asset_path" != *"validation-evidence.json" ]] || continue'); expect(workflow).toContain('cp -p -- "$PREFLIGHT_ARTIFACT_DIR/$asset_path" "$RELEASE_ASSET_DIR/$asset_name"'); From 37f5fb84401b1e06c98dc39c135c40d5cf86be00 Mon Sep 17 00:00:00 2001 From: Your Name Date: Tue, 2 Jun 2026 16:17:44 +0100 Subject: [PATCH 26/29] Guard release publish asset isolation Add explicit workflow and generator assertions that validation evidence is not staged under ARTIFACT_DIR/validation and release publishing never uses ARTIFACT_DIR globs. --- test/release-workflow-guards.test.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/test/release-workflow-guards.test.ts b/test/release-workflow-guards.test.ts index 846d039..b500390 100644 --- a/test/release-workflow-guards.test.ts +++ b/test/release-workflow-guards.test.ts @@ -41,6 +41,8 @@ describe('governed release hook guards', () => { 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; }'); @@ -66,6 +68,8 @@ describe('governed release hook guards', () => { 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; }'); From 1add087c7c8619e65a690873dc141e50c884d24c Mon Sep 17 00:00:00 2001 From: Your Name Date: Tue, 2 Jun 2026 16:33:59 +0100 Subject: [PATCH 27/29] Clarify release validation evidence staging Remove the dead publish-workflow env binding for VALIDATION_ARTIFACT_DIR so validation evidence staging is defined only by the isolated mktemp directory used in the publish script. Keep render and reusable-workflow tests aligned with that contract. --- .github/workflows/release-publish-reusable.yml | 1 - src/archetypes.ts | 1 - tests/render.test.ts | 2 +- tests/reusable-workflows.test.ts | 3 --- 4 files changed, 1 insertion(+), 6 deletions(-) diff --git a/.github/workflows/release-publish-reusable.yml b/.github/workflows/release-publish-reusable.yml index db98aae..cd35565 100644 --- a/.github/workflows/release-publish-reusable.yml +++ b/.github/workflows/release-publish-reusable.yml @@ -59,7 +59,6 @@ jobs: UPDATE_MINOR_TAG: ${{ inputs.update_minor_tag }} TAG_PREFIX: ${{ inputs.tag_prefix }} ARTIFACT_DIR: ${{ inputs.artifact_dir }} - VALIDATION_ARTIFACT_DIR: ${{ runner.temp }}/release-validation RELEASE_NOTES_FILE: ${{ inputs.release_notes_file }} PUBLISH_SCRIPT: ${{ inputs.publish_script }} POSTPUBLISH_SCRIPT: ${{ inputs.postpublish_script }} diff --git a/src/archetypes.ts b/src/archetypes.ts index 57bba30..da468b9 100644 --- a/src/archetypes.ts +++ b/src/archetypes.ts @@ -1812,7 +1812,6 @@ function releasePublishReusableWorkflow(): string { UPDATE_MINOR_TAG: \${{ inputs.update_minor_tag }} TAG_PREFIX: \${{ inputs.tag_prefix }} ARTIFACT_DIR: \${{ inputs.artifact_dir }} - VALIDATION_ARTIFACT_DIR: \${{ runner.temp }}/release-validation RELEASE_NOTES_FILE: \${{ inputs.release_notes_file }} PUBLISH_SCRIPT: \${{ inputs.publish_script }} POSTPUBLISH_SCRIPT: \${{ inputs.postpublish_script }} diff --git a/tests/render.test.ts b/tests/render.test.ts index 4e802f0..ae99a1e 100644 --- a/tests/render.test.ts +++ b/tests/render.test.ts @@ -322,8 +322,8 @@ describe("renderManagedFiles", () => { 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("VALIDATION_ARTIFACT_DIR: ${{ runner.temp }}/release-validation"); 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"); diff --git a/tests/reusable-workflows.test.ts b/tests/reusable-workflows.test.ts index ea7c139..9eafbb8 100644 --- a/tests/reusable-workflows.test.ts +++ b/tests/reusable-workflows.test.ts @@ -108,9 +108,6 @@ describe("reusable workflows", () => { expect((publish.jobs as any).publish.environment).toBe( "${{ inputs.publish_environment || 'release-publish' }}" ); - expect((publish.jobs as any).publish.steps[1].env.VALIDATION_ARTIFACT_DIR).toBe( - "${{ runner.temp }}/release-validation" - ); 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"); From 01d150ce453764c74b00cacd8b1fafd258904f1a Mon Sep 17 00:00:00 2001 From: Hephaestus Date: Tue, 2 Jun 2026 16:37:18 +0000 Subject: [PATCH 28/29] Harden governed release publish staging --- docs/bootstrap/versioning.md | 2 +- scripts/ci/run-release-build.sh | 2 +- src/archetypes.ts | 2 +- test/release-workflow-guards.test.ts | 8 +++++++- tests/render.test.ts | 1 + 5 files changed, 11 insertions(+), 4 deletions(-) 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/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/src/archetypes.ts b/src/archetypes.ts index da468b9..7276b63 100644 --- a/src/archetypes.ts +++ b/src/archetypes.ts @@ -1666,7 +1666,7 @@ function releasePreflightReusableWorkflow(): string { 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" + 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 >"$RELEASE_ASSET_DIR/release-evidence.json" < { it('binds publish provenance to the tag sha and requested run ids', () => { 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; }'); @@ -47,11 +48,14 @@ describe('governed release hook guards', () => { 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('[[ "$asset_path" != *"release-evidence.json" && "$asset_path" != *"validation-evidence.json" ]] || continue'); 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; }'); @@ -73,5 +77,7 @@ describe('governed release hook guards', () => { 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/render.test.ts b/tests/render.test.ts index ae99a1e..8d19cdf 100644 --- a/tests/render.test.ts +++ b/tests/render.test.ts @@ -329,6 +329,7 @@ describe("renderManagedFiles", () => { 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"/*'); From 9a0f6419b4c6feba84c0b7535df5a4277e17d07c Mon Sep 17 00:00:00 2001 From: Your Name Date: Tue, 2 Jun 2026 19:13:02 +0100 Subject: [PATCH 29/29] Fix generated preflight evidence path Write generated governed preflight release evidence to ARTIFACT_DIR instead of the publish-only RELEASE_ASSET_DIR variable, and add a guard test for the generator path. --- src/archetypes.ts | 2 +- test/release-workflow-guards.test.ts | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/archetypes.ts b/src/archetypes.ts index 7276b63..5d1c1f8 100644 --- a/src/archetypes.ts +++ b/src/archetypes.ts @@ -1667,7 +1667,7 @@ function releasePreflightReusableWorkflow(): string { [[ -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 >"$RELEASE_ASSET_DIR/release-evidence.json" <"$ARTIFACT_DIR/release-evidence.json" < { 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" < {