From b9d091d8f0875624ec45ad2ac426fc5e87557520 Mon Sep 17 00:00:00 2001 From: atavism Date: Tue, 27 Oct 2026 07:11:22 -0700 Subject: [PATCH] ci: restore branch nightly uploads and Slack notifications --- .github/workflows/release.yml | 10 ++-- scripts/ci/format.sh | 6 ++ scripts/ci/publish-to-s3.sh | 7 ++- scripts/ci/release_workflow_test.py | 91 +++++++++++++++++++++++++++++ 4 files changed, 107 insertions(+), 7 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 833397bdcb..322f600827 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -77,8 +77,8 @@ concurrency: jobs: set-metadata: # Allow tag-push releases, scheduled builds from default branch, - # and workflow_dispatch from any branch (non-main branches create - # draft releases that are immediately deleted for testing). + # and workflow_dispatch from any branch. Branch test runs publish versioned + # downloads and notify Slack; their temporary draft releases are deleted. if: ${{ startsWith(github.ref, 'refs/tags/') || (github.event_name == 'schedule' && github.ref_name == github.event.repository.default_branch) || github.event_name == 'workflow_dispatch' }} runs-on: ubuntu-latest outputs: @@ -694,7 +694,6 @@ jobs: always() && !cancelled() && needs.set-metadata.result == 'success' && - needs.set-metadata.outputs.is_test_run != 'true' && needs.nightly-gate.result == 'success' runs-on: ubuntu-latest env: @@ -705,6 +704,7 @@ jobs: LINUX_ARCH: ${{ needs.set-metadata.outputs.linux_arch }} BUCKET: ${{ vars.S3_RELEASES_BUCKET }} GITHUB_REF_NAME: ${{ github.ref_name }} + IS_TEST_RUN: ${{ needs.set-metadata.outputs.is_test_run }} steps: - name: Checkout code uses: actions/checkout@v4 @@ -751,7 +751,6 @@ jobs: always() && !cancelled() && needs.release-create.result == 'success' && - needs.set-metadata.outputs.is_test_run != 'true' && needs.nightly-gate.result == 'success' runs-on: ubuntu-latest env: @@ -762,6 +761,7 @@ jobs: LINUX_ARCH: ${{ needs.set-metadata.outputs.linux_arch }} BUCKET: ${{ vars.S3_RELEASES_BUCKET }} GITHUB_REF_NAME: ${{ github.ref_name }} + IS_TEST_RUN: ${{ needs.set-metadata.outputs.is_test_run }} steps: - name: Checkout code uses: actions/checkout@v4 @@ -1318,7 +1318,6 @@ jobs: always() && !cancelled() && needs.set-metadata.result == 'success' && - needs.set-metadata.outputs.is_test_run != 'true' && needs.nightly-gate.result == 'success' && needs.upload-s3.result == 'success' && needs.upload-release-artifacts.result == 'success' && @@ -1348,6 +1347,7 @@ jobs: BUCKET: ${{ vars.S3_RELEASES_BUCKET }} BUILD_TYPE: ${{ needs.set-metadata.outputs.build_type }} GITHUB_REF_NAME: ${{ github.ref_name }} + IS_TEST_RUN: ${{ needs.set-metadata.outputs.is_test_run }} LINUX_ARCH: ${{ needs.set-metadata.outputs.linux_arch }} WORKFLOW_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} shell: bash diff --git a/scripts/ci/format.sh b/scripts/ci/format.sh index 4f1775f90e..e5dc364530 100755 --- a/scripts/ci/format.sh +++ b/scripts/ci/format.sh @@ -14,6 +14,7 @@ set -euo pipefail # GITHUB_SHA: commit SHA (required for release-notes) # WORKFLOW_URL: workflow run URL (required for slack, used for nightly builds) # LINUX_ARCH: amd64, arm64, or all (optional, defaults to amd64) +# IS_TEST_RUN: true for branch builds; uses versioned downloads (default: false) FORMAT="${1:?Format required: release-notes, job-summary, or slack}" RELEASE_TAG="${RELEASE_TAG:?RELEASE_TAG required}" @@ -23,6 +24,7 @@ BUCKET="${BUCKET:?BUCKET required}" BUILD_TYPE="${BUILD_TYPE:?BUILD_TYPE required}" GITHUB_REF_NAME="${GITHUB_REF_NAME:?GITHUB_REF_NAME required}" LINUX_ARCH="${LINUX_ARCH:-amd64}" +IS_TEST_RUN="${IS_TEST_RUN:-false}" case "$LINUX_ARCH" in amd64 | arm64 | all) ;; @@ -41,6 +43,10 @@ FULL_INSTALLER_NAME="${INSTALLER_BASE_NAME}" VERSION_URL="https://s3.amazonaws.com/${BUCKET}/releases/${BUILD_TYPE}/${VERSION}" LATEST_URL="https://s3.amazonaws.com/${BUCKET}/releases/${BUILD_TYPE}/latest" +# Branch builds never update the shared nightly aliases. +if [[ "$BUILD_TYPE" == "nightly" && "$IS_TEST_RUN" == "true" ]]; then + LATEST_URL="$VERSION_URL" +fi # Check if a platform should be included should_include() { diff --git a/scripts/ci/publish-to-s3.sh b/scripts/ci/publish-to-s3.sh index 98d89ce56d..cac0cb9fce 100755 --- a/scripts/ci/publish-to-s3.sh +++ b/scripts/ci/publish-to-s3.sh @@ -17,6 +17,7 @@ set -euo pipefail # AWS_ACCESS_KEY_ID: AWS credentials (required) # AWS_SECRET_ACCESS_KEY: AWS credentials (required) # LINUX_ARCH: amd64, arm64, or all (optional, defaults to all) +# IS_TEST_RUN: true for branch builds; skips shared nightly aliases (default: false) BUILD_TYPE="${1:?Build type required}" VERSION="${2:?Version required}" @@ -25,6 +26,7 @@ PLATFORMS="${4:?Platforms required}" BUCKET="${BUCKET:?BUCKET environment variable required}" LINUX_ARCH="${LINUX_ARCH:-all}" +IS_TEST_RUN="${IS_TEST_RUN:-false}" case "$LINUX_ARCH" in amd64|arm64|all) @@ -63,15 +65,16 @@ upload_file() { aws s3 cp "$filepath" "s3://${BUCKET}/${VERSION_PREFIX}/${filename}" \ --acl public-read --metadata "sha256=${checksum}" + # Branch builds publish versioned downloads without replacing the main nightly. # Stable and beta aliases move only after the GitHub release is verified. - if [[ "$BUILD_TYPE" == "nightly" ]]; then + if [[ "$BUILD_TYPE" == "nightly" && "$IS_TEST_RUN" != "true" ]]; then aws s3 cp "$filepath" "s3://${BUCKET}/${LATEST_PREFIX}/${filename}" \ --acl public-read --metadata "sha256=${checksum}" fi echo "✓ Uploaded $filename" echo " - https://s3.amazonaws.com/${BUCKET}/${VERSION_PREFIX}/${filename}" - if [[ "$BUILD_TYPE" == "nightly" ]]; then + if [[ "$BUILD_TYPE" == "nightly" && "$IS_TEST_RUN" != "true" ]]; then echo " - https://s3.amazonaws.com/${BUCKET}/${LATEST_PREFIX}/${filename}" fi } diff --git a/scripts/ci/release_workflow_test.py b/scripts/ci/release_workflow_test.py index 5705f1ef55..5f026e13a6 100644 --- a/scripts/ci/release_workflow_test.py +++ b/scripts/ci/release_workflow_test.py @@ -73,6 +73,16 @@ def run_s3_upload(self, build_type: str, platforms: str, **env: str) -> subproce cwd=self.work, env={**self.env, **env}, text=True, capture_output=True, timeout=10, ) + def resolve_test_run_setting(self, job: str, value: str, step_name: str | None = None) -> str: + environment = dict(self.jobs[job].get("env", {})) + if step_name: + step = next(step for step in self.jobs[job]["steps"] if step["name"] == step_name) + environment.update(step.get("env", {})) + return re.sub( + r"\$\{\{\s*needs\.set-metadata\.outputs\.is_test_run\s*\}\}", value, + environment.get("IS_TEST_RUN", "false"), + ) + def run_metadata(self, tag: str, existing_tags: tuple[str, ...] = ()) -> tuple[subprocess.CompletedProcess, dict[str, str]]: # The Linux runner uses Bash 4+; macOS's system Bash lacks associative arrays. shell = None @@ -259,6 +269,87 @@ def test_upload_stages_releases_but_promotes_nightlies(self) -> None: for command in self.commands(): self.assertEqual(command[-2:], ["--metadata", f"sha256={checksum}"]) + def test_branch_nightly_uploads_without_replacing_shared_aliases(self) -> None: + directory = self.work / "lantern-installer-apk" + directory.mkdir() + filenames = ("lantern-installer-nightly.apk", "lantern-installer-nightly-armv7.apk") + for filename in filenames: + (directory / filename).write_bytes(b"branch installer") + + result = self.run_s3_upload( + "nightly", "android", IS_TEST_RUN=self.resolve_test_run_setting("upload-s3", "true"), + ) + + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual( + [command[4] for command in self.commands()], + [f"s3://release-test/releases/nightly/9.2.0/{filename}" for filename in filenames], + ) + self.assertNotIn("https://s3.amazonaws.com/release-test/releases/nightly/latest/", result.stdout) + + def test_nightly_download_messages_point_to_the_published_artifacts(self) -> None: + filenames = [ + "lantern-installer-nightly.dmg", "lantern-installer-nightly.exe", + "lantern-installer-nightly.apk", "lantern-installer-nightly-armv7.apk", + *[f"lantern-installer-nightly{suffix}.{extension}" + for suffix in ("", "-arm64") for extension in ("deb", "rpm", "pkg.tar.zst")], + ] + for is_test_run in ("false", "true"): + for message_format in ("slack", "release-notes"): + with self.subTest(is_test_run=is_test_run, message_format=message_format): + if message_format == "slack": + setting = self.resolve_test_run_setting("release-success-notify", is_test_run, "Format Slack message") + else: + setting = self.resolve_test_run_setting("upload-release-artifacts", is_test_run) + result = subprocess.run( + ["bash", str(ROOT / "scripts/ci/format.sh"), message_format], + cwd=self.work, + env={ + **self.env, "BUILD_TYPE": "nightly", "IS_TEST_RUN": setting, + "PLATFORM": "macos,windows,android,linux", + "GITHUB_REF_NAME": "feature/test" if is_test_run == "true" else "main", + "WORKFLOW_URL": "https://github.com/getlantern/lantern/actions/runs/123", + }, + text=True, capture_output=True, timeout=10, + ) + self.assertEqual(result.returncode, 0, result.stderr) + for filename in filenames: + versioned = f"https://s3.amazonaws.com/release-test/releases/nightly/9.2.0/{filename}" + latest = f"https://s3.amazonaws.com/release-test/releases/nightly/latest/{filename}" + self.assertIn(versioned, result.stdout) + self.assertEqual(result.stdout.count(latest), 0 if is_test_run == "true" else 1) + + def test_successful_branch_nightlies_can_upload_and_notify(self) -> None: + def eligible(job_name: str, is_test_run: str, *, cancelled: bool = False, + failed_job: str | None = None) -> bool: + # Exercise the workflow's conditions with realistic nightly results. + values = {f"{name}.result": "success" for name in self.jobs} + for name in ("upload-google-play", "upload-testflight", "publish-update-metadata", "verify-update-service"): + values[f"{name}.result"] = "skipped" + values.update({ + "set-metadata.outputs.is_test_run": is_test_run, + "set-metadata.outputs.build_type": "nightly", + }) + if failed_job: + values[f"{failed_job}.result"] = "failure" + condition = re.sub( + r"needs\.([\w.-]+)", lambda match: repr(values[match[1]]), + self.jobs[job_name]["if"], + ) + condition = condition.replace("always()", "True").replace("cancelled()", str(cancelled)) + condition = re.sub(r"!(?!=)", " not ", condition).replace("&&", " and ").replace("||", " or ") + return eval(" ".join(condition.split()), {"__builtins__": {}}, {}) + + for is_test_run in ("false", "true"): + for job in ("upload-s3", "upload-release-artifacts", "release-success-notify"): + with self.subTest(is_test_run=is_test_run, job=job): + self.assertTrue(eligible(job, is_test_run)) + self.assertFalse(eligible(job, is_test_run, cancelled=True)) + self.assertFalse(eligible(job, is_test_run, failed_job="nightly-gate")) + for failed_job in ("upload-s3", "upload-release-artifacts", "release-finalize"): + with self.subTest(is_test_run=is_test_run, failed_job=failed_job): + self.assertFalse(eligible("release-success-notify", is_test_run, failed_job=failed_job)) + def test_incomplete_candidate_set_blocks_all_uploads(self) -> None: directory = self.work / "lantern-installer-dmg" directory.mkdir()