Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand All @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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
Expand Down Expand Up @@ -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' &&
Expand Down Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions scripts/ci/format.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
Expand All @@ -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) ;;
Expand 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() {
Expand Down
7 changes: 5 additions & 2 deletions scripts/ci/publish-to-s3.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
Expand All @@ -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)
Expand Down Expand Up @@ -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
}
Expand Down
91 changes: 91 additions & 0 deletions scripts/ci/release_workflow_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down
Loading