name: Release on: workflow_run: workflows: ["CI"] types: [completed] branches: [master] permissions: contents: read actions: read concurrency: group: release cancel-in-progress: false jobs: promote: name: Audit and publish tested artifacts if: >- github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'push' && github.event.workflow_run.head_branch == 'master' && github.event.workflow_run.head_repository.full_name == github.repository runs-on: ubuntu-24.04 timeout-minutes: 15 permissions: contents: write actions: read env: HEAD_SHA: ${{ github.event.workflow_run.head_sha }} GH_TOKEN: ${{ github.token }} steps: - name: Checkout exact tested source uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ github.event.workflow_run.head_sha }} - name: Get version id: version shell: bash run: | version=$(node -p 'require("./package.json").version') echo "package_version=$version" >> "$GITHUB_OUTPUT" echo "tag=v$version" >> "$GITHUB_OUTPUT" - name: Inspect release and tag state without mutation env: TAG: ${{ steps.version.outputs.tag }} shell: bash run: | set -euo pipefail gh api "repos/$GITHUB_REPOSITORY/git/matching-refs/tags/$TAG" > matching-tags.json tag_count=$(jq --arg ref "refs/tags/$TAG" '[.[] | select(.ref == $ref)] | length' matching-tags.json) if [ "$tag_count" -gt 1 ]; then echo "::error::Multiple exact refs found for $TAG" exit 1 elif [ "$tag_count" -eq 1 ]; then resolved_sha=$(gh api "repos/$GITHUB_REPOSITORY/commits/$TAG" --jq .sha) jq -n --arg sha "$resolved_sha" '{exists: true, sha: $sha}' > tag-state.json else jq -n '{exists: false}' > tag-state.json fi gh api --paginate --slurp \ "repos/$GITHUB_REPOSITORY/releases?per_page=100" > release-pages.json jq --arg tag "$TAG" \ '[.[][] | select(.tag_name == $tag)]' \ release-pages.json > matching-releases.json release_count=$(jq 'length' matching-releases.json) if [ "$release_count" -gt 1 ]; then echo "::error::Multiple releases found for $TAG" exit 1 elif [ "$release_count" -eq 1 ]; then jq '.[0] | {exists: true, isDraft: .draft, tagName: .tag_name}' \ matching-releases.json > release-state.json else jq -n '{exists: false}' > release-state.json fi - name: Classify release eligibility id: release_state env: PACKAGE_VERSION: ${{ steps.version.outputs.package_version }} run: >- node scripts/release_state_gate.mjs --tag-state tag-state.json --release-state release-state.json - name: Report already-published maintenance no-op if: steps.release_state.outputs.action == 'noop' env: TAG: ${{ steps.release_state.outputs.tag }} run: echo "::notice::$TAG is already public; this tested maintenance SHA requires no release action." - name: Setup Node.js if: steps.release_state.outputs.action == 'promote' uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: 22 cache: "yarn" - name: Install dependencies if: steps.release_state.outputs.action == 'promote' run: yarn install --frozen-lockfile - name: Download native release evidence from tested CI run if: steps.release_state.outputs.action == 'promote' uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: pattern: release-evidence-* path: release-evidence merge-multiple: true run-id: ${{ github.event.workflow_run.id }} github-token: ${{ github.token }} - name: Download binaries from tested CI run if: steps.release_state.outputs.action == 'promote' uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: pattern: ExifCleaner-* path: release-assets merge-multiple: true run-id: ${{ github.event.workflow_run.id }} github-token: ${{ github.token }} - name: Prepare and validate exact release assets and SHASUMS256.txt if: steps.release_state.outputs.action == 'promote' run: >- yarn release:prepare-assets --assets release-assets --evidence release-evidence --source-sha "$HEAD_SHA" --output promotion-manifest.json - name: Validate native package structure if: steps.release_state.outputs.action == 'promote' shell: bash run: | set -euo pipefail version=$(node -p 'require("./package.json").version') file "release-assets/ExifCleaner.${version}.exe" | grep -q 'PE32' dpkg-deb --info "release-assets/exifcleaner_${version}_amd64.deb" >/dev/null file "release-assets/exifcleaner-${version}.x86_64.rpm" | grep -q 'RPM' - name: Create or reconcile exact pre-publication version tag if: steps.release_state.outputs.action == 'promote' env: TAG: ${{ steps.release_state.outputs.tag }} run: | set -euo pipefail if gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$TAG" >/dev/null 2>&1; then existing=$(gh api "repos/$GITHUB_REPOSITORY/commits/$TAG" --jq .sha) if [ "$existing" != "$HEAD_SHA" ]; then release_state=$(gh release view "$TAG" --json isDraft --jq .isDraft 2>/dev/null || true) if [ "$release_state" != true ]; then echo "::error::Refusing to move a published release tag or an orphan tag: $TAG" exit 1 fi gh api "repos/$GITHUB_REPOSITORY/git/refs/tags/$TAG" \ --method PATCH \ -f sha="$HEAD_SHA" \ -F force=true >/dev/null fi else gh api "repos/$GITHUB_REPOSITORY/git/refs" \ --method POST \ -f ref="refs/tags/$TAG" \ -f sha="$HEAD_SHA" >/dev/null fi - name: Create or update transient draft if: steps.release_state.outputs.action == 'promote' uses: softprops/action-gh-release@fe965f7af51af5f2602596916f38a38df2e33de0 # v3.0.2 with: tag_name: ${{ steps.release_state.outputs.tag }} name: ExifCleaner ${{ steps.release_state.outputs.tag }} draft: true body_path: RELEASE_NOTES.md fail_on_unmatched_files: true files: release-assets/* - name: Capture transient draft metadata if: steps.release_state.outputs.action == 'promote' env: TAG: ${{ steps.release_state.outputs.tag }} run: gh release view "$TAG" --json isDraft,tagName,body,assets,url > transient-release.json - name: Audit transient draft if: steps.release_state.outputs.action == 'promote' run: >- yarn verify:release-assets --assets release-assets --evidence release-evidence --source-sha "$HEAD_SHA" --release-json transient-release.json --release-notes RELEASE_NOTES.md --expected-draft true - name: Audit downloaded transient draft bytes if: steps.release_state.outputs.action == 'promote' env: TAG: ${{ steps.release_state.outputs.tag }} run: | mkdir remote-release-assets gh release download "$TAG" --dir remote-release-assets yarn verify:release-assets --assets remote-release-assets --evidence release-evidence --source-sha "$HEAD_SHA" - name: Publish audited release if: steps.release_state.outputs.action == 'promote' env: TAG: ${{ steps.release_state.outputs.tag }} run: | release_id=$(gh release view "$TAG" --json databaseId --jq .databaseId) gh api "repos/$GITHUB_REPOSITORY/releases/$release_id" \ --method PATCH \ -F draft=false \ -F make_latest=true >/dev/null - name: Capture published release metadata if: steps.release_state.outputs.action == 'promote' env: TAG: ${{ steps.release_state.outputs.tag }} run: gh release view "$TAG" --json isDraft,tagName,body,assets,url > published-release.json - name: Audit published release if: steps.release_state.outputs.action == 'promote' run: >- yarn verify:release-assets --assets release-assets --evidence release-evidence --source-sha "$HEAD_SHA" --release-json published-release.json --release-notes RELEASE_NOTES.md --expected-draft false - name: Audit downloaded published bytes if: steps.release_state.outputs.action == 'promote' env: TAG: ${{ steps.release_state.outputs.tag }} run: | mkdir -p published-release-assets gh release download "$TAG" --dir published-release-assets yarn verify:release-assets --assets published-release-assets --evidence release-evidence --source-sha "$HEAD_SHA"