name: Публикация пре-релиза (ручная) run-name: Publish ${{ inputs.tag }} on: workflow_dispatch: inputs: tag: description: "Exact prerelease tag, for example v1.61.0-beta.4" required: true type: string permissions: contents: write actions: read issues: read concurrency: group: publish-prerelease-${{ inputs.tag }} cancel-in-progress: false jobs: gate: name: "Гейт: зелёная Проверка и релизный контракт" runs-on: ubuntu-24.04 # Больше ожидания зелёного Validate в release-gate.mjs (до 60 мин) (#658). timeout-minutes: 75 outputs: sha: ${{ steps.candidate.outputs.sha }} tag: ${{ steps.candidate.outputs.tag }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: ref: ${{ github.sha }} fetch-depth: 0 - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 with: { node-version: 22 } - name: Pin the dev candidate or the existing annotated tag id: candidate env: TAG: ${{ inputs.tag }} REF_NAME: ${{ github.ref_name }} run: | set -euo pipefail test "$REF_NAME" = "dev" || { echo "::error::Prereleases must be dispatched from the dev branch, got $REF_NAME" exit 1 } DISPATCHED_SHA=$(git rev-parse HEAD) git fetch --force origin dev --tags REMOTE=$(git ls-remote --tags origin "refs/tags/$TAG" "refs/tags/$TAG^{}") if [ -n "$REMOTE" ]; then SHA=$(printf '%s\n' "$REMOTE" | awk -v ref="refs/tags/$TAG^{}" '$2 == ref {print $1}') test -n "$SHA" || { echo "::error::Existing remote tag $TAG is not annotated" exit 1 } else SHA=$DISPATCHED_SHA test "$(git rev-parse origin/dev)" = "$SHA" || { echo "::error::The dispatched SHA is no longer the origin/dev tip" exit 1 } fi git checkout --detach "$SHA" echo "sha=$SHA" >> "$GITHUB_OUTPUT" echo "tag=$TAG" >> "$GITHUB_OUTPUT" - name: Verify version, changelogs and bilingual release notes env: TAG: ${{ inputs.tag }} run: node scripts/release-contract.mjs "$TAG" --repo="$GITHUB_REPOSITORY" # #479: тяжёлые job Validate идут только на коммите с трейлером `Release:`. # Зелёный Validate без трейлера означал бы прогон без смоков и golden — # класс тихого пропуска #171/#207, поэтому трейлер проверяется здесь явно. - name: Require the Release trailer on the candidate commit env: SHA: ${{ steps.candidate.outputs.sha }} run: | set -euo pipefail git log -1 --format=%B "$SHA" > /tmp/head-message.txt if ! grep -Eq '^Release:[[:space:]]*v?[0-9]+\.[0-9]+\.[0-9]+' /tmp/head-message.txt; then echo "::error::Candidate $SHA has no Release: trailer — Validate ran without the heavy gates (#479)" exit 1 fi # #479: свежесть скриншотов на обычном пуше — предупреждение; на # кандидате она обязана быть доказана строгим режимом. - name: Documentation screenshots are fresh for the candidate run: node scripts/check-docs.mjs --screenshots=strict - name: Require green Validate for this exact SHA env: GH_TOKEN: ${{ github.token }} REPO: ${{ github.repository }} SHA: ${{ steps.candidate.outputs.sha }} run: node scripts/release-gate.mjs "$SHA" - name: Bind issue membership to the exact candidate env: GH_TOKEN: ${{ github.token }} TAG: ${{ steps.candidate.outputs.tag }} SHA: ${{ steps.candidate.outputs.sha }} run: | set -euo pipefail mkdir -p release-membership if gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then gh release download "$TAG" --repo "$GITHUB_REPOSITORY" \ --dir release-membership --pattern RELEASE-MEMBERSHIP.json --clobber || true fi if [ -s release-membership/RELEASE-MEMBERSHIP.json ]; then node scripts/release-membership.mjs verify --tag="$TAG" --candidate="$SHA" \ --input=release-membership/RELEASE-MEMBERSHIP.json else ISSUES=$(gh issue list --repo "$GITHUB_REPOSITORY" --state open \ --label S8-merged --limit 1000 --json number --jq 'map(.number)|join(",")') node scripts/release-membership.mjs create --tag="$TAG" --candidate="$SHA" \ --issues="$ISSUES" --allow-unmatched \ --output=release-membership/RELEASE-MEMBERSHIP.json fi - name: Preserve candidate membership for publication uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: release-membership path: release-membership/RELEASE-MEMBERSHIP.json if-no-files-found: error retention-days: 7 publish: name: Публикация тега и релиза needs: gate runs-on: ubuntu-24.04 timeout-minutes: 20 outputs: url: ${{ steps.verify.outputs.url }} newly_published: ${{ steps.release.outputs.newly_published }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: ref: ${{ needs.gate.outputs.sha }} fetch-depth: 0 - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 with: { node-version: 22 } - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 with: name: release-membership path: release-assets - name: Build and verify both release assets before publication env: TAG: ${{ needs.gate.outputs.tag }} SHA: ${{ needs.gate.outputs.sha }} run: | set -euo pipefail npm ci npm run build node scripts/bundle-tree.mjs dist custom_components/houseplan/frontend npm run bundle:budget VERSION=${TAG#v} grep -RFq "$VERSION" dist # #540: тот же способ, что у release.yml и release-prerelease.mjs — # архив закоммиченного дерева точного коммита, детерминированный. git -c core.autocrlf=false archive --format=zip --output=houseplan.zip \ "$SHA:custom_components/houseplan" node scripts/verify-houseplan-zip.mjs houseplan.zip \ custom_components/houseplan/frontend "$VERSION" test -s dist/houseplan-card.js test -s dist/houseplan-panel.js test -s houseplan.zip mkdir -p release-assets cp dist/houseplan-card.js houseplan.zip release-assets/ node scripts/release-membership.mjs verify --tag="$TAG" --candidate="$SHA" \ --input=release-assets/RELEASE-MEMBERSHIP.json node scripts/release-assets.mjs sums release-assets --include-membership - name: Create or verify the annotated tag env: TAG: ${{ needs.gate.outputs.tag }} SHA: ${{ needs.gate.outputs.sha }} run: | set -euo pipefail REMOTE=$(git ls-remote --tags origin "refs/tags/$TAG" "refs/tags/$TAG^{}") if [ -n "$REMOTE" ]; then PEELED=$(printf '%s\n' "$REMOTE" | awk -v ref="refs/tags/$TAG^{}" '$2 == ref {print $1}') test -n "$PEELED" || { echo "::error::Existing remote tag $TAG is not annotated" exit 1 } test "$PEELED" = "$SHA" || { echo "::error::Existing tag $TAG points to $PEELED, expected $SHA" exit 1 } git fetch --force origin "refs/tags/$TAG:refs/tags/$TAG" test "$(git cat-file -t "refs/tags/$TAG")" = "tag" else git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" git tag -a "$TAG" "$SHA" -m "$TAG" git push origin "$TAG" fi - name: Stage, verify and publish the prerelease id: release env: GH_TOKEN: ${{ github.token }} TAG: ${{ needs.gate.outputs.tag }} run: | set -euo pipefail if ! gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then gh release create "$TAG" --repo "$GITHUB_REPOSITORY" --verify-tag \ --draft --prerelease --title "$TAG" --notes-file docs/RELEASE-NOTES.md fi WAS_DRAFT=$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json isDraft --jq .isDraft) echo "newly_published=$WAS_DRAFT" >> "$GITHUB_OUTPUT" if [ "$WAS_DRAFT" = "false" ]; then mkdir -p existing-public if gh release download "$TAG" --repo "$GITHUB_REPOSITORY" --dir existing-public \ --pattern houseplan-card.js --pattern houseplan.zip \ --pattern RELEASE-MEMBERSHIP.json --pattern SHA256SUMS --clobber \ && diff -u release-assets/SHA256SUMS existing-public/SHA256SUMS \ && node scripts/release-assets.mjs check existing-public release-assets/SHA256SUMS \ && node scripts/release-membership.mjs verify --tag="$TAG" --candidate="${{ needs.gate.outputs.sha }}" \ --input=existing-public/RELEASE-MEMBERSHIP.json; then echo "release is already public and byte-identical; publication skipped" exit 0 fi echo "existing public assets need recovery; verified files will be uploaded again" fi gh release upload "$TAG" release-assets/houseplan-card.js release-assets/houseplan.zip \ release-assets/RELEASE-MEMBERSHIP.json release-assets/SHA256SUMS \ --repo "$GITHUB_REPOSITORY" --clobber RELEASE_JSON=$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" \ --json tagName,isDraft,isPrerelease,assets,url) export RELEASE_JSON TAG node <<'NODE' const release = JSON.parse(process.env.RELEASE_JSON); if (release.tagName !== process.env.TAG) throw new Error('release tag mismatch'); const assets = new Map(release.assets.map((asset) => [asset.name, asset])); for (const name of ['houseplan-card.js', 'houseplan.zip', 'RELEASE-MEMBERSHIP.json', 'SHA256SUMS']) { if (!(Number(assets.get(name)?.size) > 0)) throw new Error(`${name} is missing or empty`); } NODE gh release edit "$TAG" --repo "$GITHUB_REPOSITORY" --draft=false --prerelease \ --title "$TAG" --notes-file docs/RELEASE-NOTES.md - name: Verify the public release and assets id: verify env: GH_TOKEN: ${{ github.token }} TAG: ${{ needs.gate.outputs.tag }} SHA: ${{ needs.gate.outputs.sha }} run: | set -euo pipefail RELEASE_JSON=$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" \ --json tagName,isDraft,isPrerelease,assets,url) export RELEASE_JSON TAG node <<'NODE' const release = JSON.parse(process.env.RELEASE_JSON); if (release.tagName !== process.env.TAG || release.isDraft || !release.isPrerelease) throw new Error('release is not a public prerelease for the requested tag'); const assets = new Map(release.assets.map((asset) => [asset.name, asset])); for (const name of ['houseplan-card.js', 'houseplan.zip', 'RELEASE-MEMBERSHIP.json', 'SHA256SUMS']) { if (!(Number(assets.get(name)?.size) > 0)) throw new Error(`${name} is missing or empty`); } NODE # #540: публичные байты — ровно те, что собраны и проверены выше. mkdir -p public gh release download "$TAG" --repo "$GITHUB_REPOSITORY" --dir public \ --pattern houseplan-card.js --pattern houseplan.zip \ --pattern RELEASE-MEMBERSHIP.json --pattern SHA256SUMS --clobber diff -u release-assets/SHA256SUMS public/SHA256SUMS node scripts/release-assets.mjs check public release-assets/SHA256SUMS node scripts/release-membership.mjs verify --tag="$TAG" --candidate="$SHA" \ --input=public/RELEASE-MEMBERSHIP.json test "$(git rev-list -n 1 "$TAG")" = "$SHA" URL=$(node -p "JSON.parse(process.env.RELEASE_JSON).url") echo "url=$URL" >> "$GITHUB_OUTPUT" printf '### Published %s\n\n- exact SHA: `%s`\n- [GitHub prerelease](%s)\n\n```\n%s```\n' \ "$TAG" "$SHA" "$URL" "$(cat public/SHA256SUMS)" >> "$GITHUB_STEP_SUMMARY" - name: Verify HACS prerelease discovery order uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 env: EXPECTED_TAG: ${{ needs.gate.outputs.tag }} with: script: | const releases = await github.paginate(github.rest.repos.listReleases, { owner: context.repo.owner, repo: context.repo.repo, per_page: 100, }); const first = releases.find((release) => release.prerelease && !release.draft); if (first?.tag_name !== process.env.EXPECTED_TAG) { core.setFailed( `HACS prerelease discovery is stale: ${first?.tag_name ?? 'none'} precedes ` + process.env.EXPECTED_TAG, ); } # #547: bookkeeping is driven by the immutable candidate manifest, not by the # mutable S8 queue. It also runs on a verified retry of an already-public beta. close-merged: name: Закрытие вошедших issue needs: [gate, publish] runs-on: ubuntu-24.04 timeout-minutes: 15 permissions: contents: read actions: read # Deliberately the stock token, not a PAT: events caused by GITHUB_TOKEN do # not start workflows, so removing the label cannot wake the review # pipeline. A PAT here would build a cascade out of a bookkeeping step. issues: write steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: ref: ${{ needs.gate.outputs.sha }} fetch-depth: 0 - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 with: { node-version: 22 } - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 with: name: release-membership path: release-membership - name: Finish only the issues proven in the candidate manifest env: GH_TOKEN: ${{ github.token }} REPO: ${{ github.repository }} TAG: ${{ needs.gate.outputs.tag }} URL: ${{ needs.publish.outputs.url }} run: | set -euo pipefail node scripts/release-bookkeeping.mjs --repo="$REPO" --tag="$TAG" \ --candidate="${{ needs.gate.outputs.sha }}" --url="$URL" \ --membership=release-membership/RELEASE-MEMBERSHIP.json announce: name: Комментарий о публикации needs: [gate, publish] if: ${{ needs.publish.outputs.newly_published == 'true' }} uses: ./.github/workflows/announce.yml with: reusable: true tag: ${{ needs.gate.outputs.tag }} release_name: ${{ needs.gate.outputs.tag }} url: ${{ needs.publish.outputs.url }} prerelease: true ref: ${{ needs.gate.outputs.tag }} secrets: inherit