Skip to content

Commit

Permalink
Add release link as output (#9)
Browse files Browse the repository at this point in the history
  • Loading branch information
danielebra authored Mar 5, 2024
1 parent 4598fb6 commit d26d921
Show file tree
Hide file tree
Showing 5 changed files with 239 additions and 18 deletions.
122 changes: 122 additions & 0 deletions .github/workflows/integration-test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
name: Integration Test
on:
workflow_dispatch:
inputs:
candidate-branch:
description: Branch to use as the candidate delta
type: string
required: true
jobs:
candidate-specified:
name: Candidate with specified branch
permissions:
id-token: write
contents: write
uses: ./.github/workflows/reusable-update-release-candidate-notes.yml
with:
branch-with-candidate-code: ${{ inputs.candidate-branch }}

candidate-inferred:
needs: [candidate-specified]
name: Candidate with default branch
permissions:
id-token: write
contents: write
uses: ./.github/workflows/reusable-update-release-candidate-notes.yml

publish-release-no-candidate:
name: Publish live release and don't alter candidate
permissions:
id-token: write
contents: write
uses: ./.github/workflows/reusable-create-release-notes.yml
with:
auto-clear-release-candidate-notes: false
tag: integration-test-nc-${{ github.run_id }}

publish-release-clear-candidate:
needs:
[publish-release-no-candidate, candidate-specified, candidate-inferred]
name: Publish live release and clear candidate
permissions:
id-token: write
contents: write
uses: ./.github/workflows/reusable-create-release-notes.yml
with:
tag: integration-test-wc-${{ github.run_id }}

assert-url:
name: Check Release URLs
needs:
[
candidate-specified,
candidate-inferred,
publish-release-no-candidate,
publish-release-clear-candidate,
]
permissions:
id-token: write
contents: write
runs-on: ubuntu-latest
steps:
- name: Assert Release URLs
uses: actions/github-script@v7
with:
script: |
const urls = {
'candidate-specified': '${{ needs.candidate-specified.outputs.URL }}',
'candidate-inferred': '${{ needs.candidate-inferred.outputs.URL }}',
'publish-release-no-candidate': '${{ needs.publish-release-no-candidate.outputs.URL }}',
'publish-release-clear-candidate': '${{ needs.publish-release-clear-candidate.outputs.URL }}',
};
for (const [job, url] of Object.entries(urls)) {
console.log(`Checking URL for ${job}: ${url}`);
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to retrieve release ${url}: ${response.statusText}`);
}
console.log(`URL is valid: ${url}`);
}
cleanup:
name: Cleanup
needs: [assert-url]
if: always()
runs-on: ubuntu-latest
permissions:
id-token: write
contents: write
steps:
- name: Delete Resources
uses: actions/github-script@v7
with:
script: |
const owner = context.repo.owner;
const repo = context.repo.repo;
const tags = ['integration-test-nc-${{ github.run_id }}', 'integration-test-wc-${{ github.run_id }}'];
for (const tag of tags) {
// Fetch the release by tag
const release = await github.rest.repos.getReleaseByTag({
owner,
repo,
tag,
});
// Delete the release
await github.rest.repos.deleteRelease({
owner,
repo,
release_id: release.data.id,
});
// Delete the tag
await github.rest.git.deleteRef({
owner,
repo,
ref: `tags/${tag}`,
});
console.log(`Release and tag deleted: ${tag}`);
}
12 changes: 11 additions & 1 deletion .github/workflows/reusable-create-release-notes.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ on:
type: boolean
default: true
description: Disable this to prevent the auto management of release candidate notes
outputs:
URL:
value: ${{ jobs.create-release.outputs.URL }}
description: Link to the release candidate
workflow_dispatch:
inputs:
tag:
Expand All @@ -27,6 +31,8 @@ jobs:
permissions:
id-token: write
contents: write
outputs:
URL: ${{ steps.release.outputs.URL }}

steps:
- name: Check out code
Expand All @@ -40,11 +46,15 @@ jobs:
path: simple-release-notes

- name: Create Release
id: release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPO: ${{ github.repository }}
shell: bash
run: python3 ./simple-release-notes/release_manager.py release ${{ inputs.tag }}
run: |
set -e
URL=$(python3 ./simple-release-notes/release_manager.py release ${{ inputs.tag }})
echo "URL=$URL" >> $GITHUB_OUTPUT
clear-candidate:
name: Clear Release Candidate
Expand Down
10 changes: 9 additions & 1 deletion .github/workflows/reusable-update-release-candidate-notes.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ on:
CANDIDATE_COMMIT:
value: ${{ jobs.update-candidate.outputs.CANDIDATE_COMMIT }}
description: The latest commit hash from the candidate branch
URL:
value: ${{ jobs.update-candidate.outputs.URL }}
description: Link to the release candidate
workflow_dispatch:
inputs:
branch-with-candidate-code:
Expand All @@ -27,6 +30,7 @@ jobs:
outputs:
CANDIDATE_BRANCH: ${{ steps.candidate-branch.outputs.BRANCH }}
CANDIDATE_COMMIT: ${{ steps.fetch.outputs.LATEST_CANDIDATE_COMMIT }}
URL: ${{ steps.update.outputs.URL }}

steps:
- name: Check out code
Expand Down Expand Up @@ -74,8 +78,12 @@ jobs:
git push origin refs/tags/release-candidate --force
- name: Update release candidate
id: update
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPO: ${{ github.repository }}
shell: bash
run: python3 ./simple-release-notes/release_manager.py candidate update
run: |
set -e
URL=$(python3 ./simple-release-notes/release_manager.py candidate update)
echo "URL=$URL" >> $GITHUB_OUTPUT
35 changes: 35 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,41 @@

A batteries-included and opinionated drop-in solution to generating and managing github release notes.

## Overview

Two main workflows

### `reusable-update-release-candidate-notes.yml`

#### Inputs

| Input | Required | Type | Default | Description |
|------------------------------|----------|--------|---------|-----------------------------------------------------------------------------------------------------------------------------------------|
| `branch-with-candidate-code` | No | string | | Optionally provide a specific branch that contains the candidate code, otherwise the default branch of the repository will be selected. |

#### Outputs

| Output | Description |
|--------------------|---------------------------------------------------------|
| `CANDIDATE_BRANCH` | The branch that was used to generate release notes from |
| `CANDIDATE_COMMIT` | The latest commit hash from the candidate branch |
| `URL` | Link to the release candidate |

### `reusable-create-release-notes.yml`

#### Inputs

| Input | Required | Type | Default | Description |
|--------------------------------------|----------|---------|---------|--------------------------------------------------------------|
| `tag` | Yes | string | | Tag to associate the release to. Can be new or existing. |
| `auto-clear-release-candidate-notes` | No | boolean | `true` | Whether the notes of the release candidate should be cleared |

#### Outputs

| Output | Description |
|--------|---------------------|
| `URL` | Link to the release |

## Setup

### `changelog` via `release.yml`
Expand Down
78 changes: 62 additions & 16 deletions release_manager.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,39 @@
import argparse
import logging
import os
import sys
from typing import Dict, Optional

import requests


class InfoFilter(logging.Filter):
def filter(self, record):
return record.levelno == logging.INFO

class SimpleReleaseNotesError(Exception):
pass


# Only route info logs to stdout, the rest goes to stderr
LOGGER = logging.getLogger(__name__)
LOGGER.setLevel(logging.DEBUG)

info_handler = logging.StreamHandler(sys.stdout)
info_handler.setLevel(logging.INFO)
info_handler.addFilter(InfoFilter())
info_format = logging.Formatter("%(message)s")
info_handler.setFormatter(info_format)

other_handler = logging.StreamHandler(sys.stderr)
other_handler.setLevel(logging.DEBUG)
other_format = logging.Formatter("%(levelname)s - %(message)s")
other_handler.setFormatter(other_format)

LOGGER.addHandler(info_handler)
LOGGER.addHandler(other_handler)


class Github:
def __init__(self, token: str, repo: str) -> None:
if None in (token, repo):
Expand Down Expand Up @@ -37,10 +66,13 @@ def create_release(
}
response = requests.post(url, json=data, headers=self._headers)
if response.status_code == 201:
print(f"Release created for tag: {release_tag}")
LOGGER.debug(f"Release created for tag: {release_tag}")
return response.json()
else:
print(f"Failed to create release for {release_tag}: {response.content}")
return response.json()
LOGGER.error(
f"Failed to create release for {release_tag}: {response.content}"
)
raise SimpleReleaseNotesError("Failed to create release")

def update_release(
self,
Expand Down Expand Up @@ -72,9 +104,13 @@ def update_release(

response = requests.patch(url, json=data, headers=self._headers)
if response.status_code == 200:
print(f"Release updated for id: {release_id}")
LOGGER.debug(f"Release updated for id: {release_id}")
else:
print(f"Failed to update release for {release_id}: {response.json()}")
LOGGER.error(
f"Failed to update release for {release_id}: {response.json()}"
)
raise SimpleReleaseNotesError("Failed to update release")
return response.json()

def generate_release_notes(
self, release_tag: str, previous_tag: Optional[str]
Expand All @@ -87,8 +123,8 @@ def generate_release_notes(
data["previous_tag_name"] = previous_tag
response = requests.post(url, json=data, headers=self._headers)
if response.status_code != 200:
print(f"Failed to generate release notes for {release_tag}")
return None
LOGGER.error(f"Failed to generate release notes for {release_tag}")
raise SimpleReleaseNotesError("Failed to generate release notes")
payload = response.json()
return {"title": payload["name"], "body": payload["body"]}

Expand All @@ -104,7 +140,9 @@ def get_release_by_tag(self, tag_name) -> Optional[Dict]:
if response.status_code == 404:
return None
if response.status_code != 200:
print(f"Unexpected response {response.status_code} {response.content}")
LOGGER.error(
f"Unexpected response {response.status_code} {response.content}"
)
return None
return response.json()

Expand Down Expand Up @@ -146,6 +184,12 @@ def create(self, new_tag: str):
)


def extract_release_link_from_response(response: Optional[dict]) -> Optional[str]:
if not response:
return None
return response.get("html_url")


class ReleaseCandidate:
TAG = "release-candidate"

Expand All @@ -167,14 +211,14 @@ def create_release_candidate(self) -> Dict:
make_latest="false",
)

def update_release_candidate(self):
def update_release_candidate(self) -> dict:
latest_release = self.github.get_latest_release_tag()
release_notes = self.github.generate_release_notes(
"release-candidate", latest_release
)
if not release_notes:
raise RuntimeError("Release notes did not generate")
self.github.update_release(
return self.github.update_release(
release_id=self.release_id,
title="Release Candidate",
body=release_notes["body"],
Expand All @@ -183,8 +227,8 @@ def update_release_candidate(self):
make_latest="false",
)

def empty(self):
self.github.update_release(
def empty(self) -> dict:
return self.github.update_release(
release_id=self.release_id,
title="Release Candidate",
body="Nothing here at the moment...",
Expand Down Expand Up @@ -244,15 +288,17 @@ def main():
if args.command == "candidate":
candidate_handler = ReleaseCandidate(github)
if args.candidate_command == "update":
return candidate_handler.update_release_candidate()
return extract_release_link_from_response(
candidate_handler.update_release_candidate()
)
if args.candidate_command == "clear":
return candidate_handler.empty()
return extract_release_link_from_response(candidate_handler.empty())

if args.command == "release":
return NewRelease(github).create(args.tag_name)
return extract_release_link_from_response(NewRelease(github).create(args.tag_name))

return parser.print_help()


if __name__ == "__main__":
print(main())
LOGGER.info(main())

0 comments on commit d26d921

Please sign in to comment.