Skip to content
Merged
104 changes: 104 additions & 0 deletions .github/workflows/release-announcements.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
name: Release Announcements (drafts)

# Drafts-only first phase of openemr/openemr-devops#711 (tracked in #719).
# On openemr-tag dispatch (or workflow_dispatch), renders a per-channel
# announcement for forum/chat/X/Facebook/LinkedIn/mailing-list and surfaces
# them as a workflow artifact + step summary. Maintainer copy/pastes onto
# each channel; mailing list is sent separately via openemr-registration's
# oe-sender.js, which takes the rendered mail.html + mail.subject.txt files
# this workflow produces. No posting, no SMTP, no API integration.

on:
repository_dispatch:
types: [openemr-tag]
workflow_dispatch:
inputs:
version:
description: 'Release version (e.g. 8.1.0)'
required: true
type: string
tag:
description: 'Annotated release tag (e.g. v8_1_0)'
required: true
type: string
branch:
description: 'Release branch (e.g. rel-810)'
required: true
type: string
forum_url:
description: 'Per-release Discourse thread URL (optional; placeholder used if blank)'
required: false
type: string

permissions:
contents: read

concurrency:
group: release-announcements-${{ github.event.client_payload.data.tag || inputs.tag }}
cancel-in-progress: false

jobs:
draft:
name: Render announcement drafts
runs-on: ubuntu-24.04
env:
OUTPUT_DIR: ${{ github.workspace }}/out/announcements
steps:
- name: Checkout
uses: actions/checkout@v6

- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.5'

- name: Install Task
uses: arduino/setup-task@v2

- name: Install release-tools dependencies
working-directory: tools/release
run: composer install --no-interaction --no-progress --prefer-dist

- name: Derive release inputs
id: inputs
working-directory: tools/release
env:
# repository_dispatch path: PAYLOAD_FILE='-' on stdin, all other vars empty.
# workflow_dispatch path: PAYLOAD_FILE empty, VERSION/TAG/BRANCH/FORUM_URL set.
# The PHP CLI rejects mixing the two and validates each field against
# the canonical dispatch.schema.json patterns; missing/null/malformed
# envelopes abort here instead of producing artifacts that reference "null".
PAYLOAD_FILE: ${{ github.event_name == 'repository_dispatch' && '-' || '' }}
VERSION: ${{ inputs.version }}
TAG: ${{ inputs.tag }}
BRANCH: ${{ inputs.branch }}
FORUM_URL: ${{ inputs.forum_url }}
CLIENT_PAYLOAD: ${{ toJSON(github.event.client_payload) }}
run: |
printf '%s' "${CLIENT_PAYLOAD}" \
| task release:derive-announcement-inputs \
>> "${GITHUB_OUTPUT}"

Comment thread
kojiromike marked this conversation as resolved.
- name: Render announcements
working-directory: tools/release
env:
VERSION: ${{ steps.inputs.outputs.version }}
TAG: ${{ steps.inputs.outputs.tag }}
BRANCH: ${{ steps.inputs.outputs.branch }}
FORUM_URL: ${{ steps.inputs.outputs.forum_url }}
run: task release:render-announcements

- name: Write step summary
working-directory: tools/release
env:
VERSION: ${{ steps.inputs.outputs.version }}
TAG: ${{ steps.inputs.outputs.tag }}
FORUM_URL: ${{ steps.inputs.outputs.forum_url }}
run: OUTPUT="${GITHUB_STEP_SUMMARY}" task release:render-announcement-step-summary

- name: Upload announcement drafts
uses: actions/upload-artifact@v7
with:
name: release-announcements
path: ${{ env.OUTPUT_DIR }}/
if-no-files-found: error
Comment thread
kojiromike marked this conversation as resolved.
40 changes: 40 additions & 0 deletions tools/release/Taskfile.yml
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,46 @@ tasks:
--event-type={{shellQuote .EVENT_TYPE}}
--payload-file={{shellQuote .PAYLOAD_FILE}}

release:derive-announcement-inputs:
desc: Emit version/tag/branch/forum_url lines for the announcements workflow
deps: [setup]
cmds:
- >-
php bin/derive-announcement-inputs.php
{{if .PAYLOAD_FILE}}--payload-file={{shellQuote .PAYLOAD_FILE}}{{end}}
{{if .VERSION}}--release-version={{shellQuote .VERSION}}{{end}}
{{if .TAG}}--release-tag={{shellQuote .TAG}}{{end}}
{{if .BRANCH}}--release-branch={{shellQuote .BRANCH}}{{end}}
{{if .FORUM_URL}}--forum-url={{shellQuote .FORUM_URL}}{{end}}

release:render-announcements:
desc: Render per-channel release-announcement drafts to a directory
requires:
vars: [OUTPUT_DIR, VERSION, TAG, BRANCH]
deps: [setup]
cmds:
- >-
php bin/render-announcements.php
--output-dir={{shellQuote .OUTPUT_DIR}}
--release-version={{shellQuote .VERSION}}
--release-tag={{shellQuote .TAG}}
--release-branch={{shellQuote .BRANCH}}
{{if .FORUM_URL}}--forum-url={{shellQuote .FORUM_URL}}{{end}}

release:render-announcement-step-summary:
desc: Render the GitHub Actions step-summary markdown for the announcement drafts
requires:
vars: [OUTPUT_DIR, VERSION, TAG]
deps: [setup]
cmds:
- >-
php bin/render-announcement-step-summary.php
--output-dir={{shellQuote .OUTPUT_DIR}}
--release-version={{shellQuote .VERSION}}
--release-tag={{shellQuote .TAG}}
{{if .FORUM_URL}}--forum-url={{shellQuote .FORUM_URL}}{{end}}
{{if .OUTPUT}}--output={{shellQuote .OUTPUT}}{{end}}

release:open-rotation-pr:
desc: Open or update the long-lived rotation draft PR
requires:
Expand Down
106 changes: 106 additions & 0 deletions tools/release/bin/derive-announcement-inputs.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
#!/usr/bin/env php
<?php

/**
* Emit the `version=` / `tag=` / `branch=` / `forum_url=` lines the
* release-announcements workflow appends to $GITHUB_OUTPUT, regardless
* of whether the trigger was an `openemr-tag` repository_dispatch
* (--payload-file) or a manual workflow_dispatch (--release-version /
* --release-tag / --release-branch / --forum-url).
*
* Validation lives in AnnouncementDispatchPayload (mirrors the canonical
* dispatch.schema.json patterns); a missing or malformed field aborts
* the step instead of producing artifacts that reference "null".
*
* @package openemr-devops
* @link https://www.open-emr.org
* @author Michael A. Smith <michael@opencoreemr.com>
* @copyright Copyright (c) 2026 OpenCoreEMR Inc.
* @license https://github.com/openemr/openemr-devops/blob/master/LICENSE GNU General Public License 3
*/

declare(strict_types=1);

require dirname(__DIR__) . '/vendor/autoload.php';

use OpenEMR\Release\AnnouncementDispatchPayload;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\ConsoleOutputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\SingleCommandApplication;

(new SingleCommandApplication())
->setName('derive-announcement-inputs')
->setDescription('Emit version/tag/branch/forum_url lines for the announcements workflow')
->addOption(
'payload-file',
null,
InputOption::VALUE_REQUIRED,
"Path to openemr-tag JSON envelope (use '-' for stdin). Mutually exclusive with --release-* flags.",
)
->addOption('release-version', null, InputOption::VALUE_REQUIRED, 'Release version (e.g. 8.1.0)')
->addOption('release-tag', null, InputOption::VALUE_REQUIRED, 'Annotated release tag (e.g. v8_1_0)')
->addOption('release-branch', null, InputOption::VALUE_REQUIRED, 'Release branch (e.g. rel-810)')
->addOption(
'forum-url',
null,
InputOption::VALUE_REQUIRED,
'Per-release Discourse thread URL; empty value falls back to the placeholder',
'',
)
->setCode(function (InputInterface $input, OutputInterface $output): int {
// Stdout is reserved for the GITHUB_OUTPUT key=value lines the
// workflow appends with `>>`. Errors must not pollute it.
$err = $output instanceof ConsoleOutputInterface ? $output->getErrorOutput() : $output;
$str = static function (string $name) use ($input): string {
$value = $input->getOption($name);
return is_string($value) ? $value : '';
};
$payloadFile = $str('payload-file');
$version = $str('release-version');
$tag = $str('release-tag');
$branch = $str('release-branch');

$flagsProvided = array_filter(
[$version, $tag, $branch],
static fn (string $v): bool => $v !== '',
);
if ($payloadFile !== '' && $flagsProvided !== []) {
$err->writeln(
'<error>--payload-file is mutually exclusive with --release-* flags</error>',
);
return 1;
}
if ($payloadFile === '' && count($flagsProvided) !== 3) {
$err->writeln(
'<error>Provide either --payload-file or all of'
. ' --release-version/--release-tag/--release-branch</error>',
);
return 1;
}

try {
$payload = $payloadFile !== ''
? AnnouncementDispatchPayload::fromPayloadFile($payloadFile)
: new AnnouncementDispatchPayload($version, $tag, $branch);
} catch (\JsonException $e) {
$err->writeln(sprintf('<error>Payload is not valid JSON: %s</error>', $e->getMessage()));
return 1;
} catch (\RuntimeException $e) {
$err->writeln(sprintf('<error>%s</error>', $e->getMessage()));
return 1;
}

// Emit forum_url verbatim (possibly empty); downstream renderers
// substitute their own placeholder when the maintainer hasn't
// supplied a real URL. Keeping the placeholder string out of the
// pipeline avoids Taskfile/Go-template confusion over the literal
// braces.
$output->writeln(sprintf('version=%s', $payload->version));
$output->writeln(sprintf('tag=%s', $payload->tag));
$output->writeln(sprintf('branch=%s', $payload->branch));
$output->writeln(sprintf('forum_url=%s', $str('forum-url')));
return 0;
})
->run();
86 changes: 86 additions & 0 deletions tools/release/bin/render-announcement-step-summary.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
#!/usr/bin/env php
<?php

/**
* Render the GitHub Actions step-summary markdown for the
* release-announcements workflow.
*
* Reads the per-channel files AnnouncementRenderer wrote into
* --output-dir and emits the summary markdown to stdout (or --output);
* the workflow appends it to $GITHUB_STEP_SUMMARY.
*
* @package openemr-devops
* @link https://www.open-emr.org
* @author Michael A. Smith <michael@opencoreemr.com>
* @copyright Copyright (c) 2026 OpenCoreEMR Inc.
* @license https://github.com/openemr/openemr-devops/blob/master/LICENSE GNU General Public License 3
*/

declare(strict_types=1);

require dirname(__DIR__) . '/vendor/autoload.php';

use OpenEMR\Release\AnnouncementRenderer;
use OpenEMR\Release\AnnouncementStepSummaryRenderer;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\ConsoleOutputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\SingleCommandApplication;

(new SingleCommandApplication())
->setName('render-announcement-step-summary')
->setDescription('Render the GitHub Actions step-summary markdown for the announcement drafts')
->addOption(
'template-dir',
null,
InputOption::VALUE_REQUIRED,
'Twig template directory (defaults to the binary\'s sibling templates/ dir)',
)
->addOption('output-dir', null, InputOption::VALUE_REQUIRED, 'Directory containing the per-channel rendered files')
->addOption('release-version', null, InputOption::VALUE_REQUIRED, 'Release version (e.g. 8.1.0)')
->addOption('release-tag', null, InputOption::VALUE_REQUIRED, 'Annotated release tag (e.g. v8_1_0)')
->addOption(
'forum-url',
null,
InputOption::VALUE_REQUIRED,
'Forum URL value rendered into the summary; defaults to the placeholder',
AnnouncementRenderer::FORUM_URL_PLACEHOLDER,
)
->addOption('output', 'o', InputOption::VALUE_REQUIRED, 'Output file (defaults to stdout)')
->setCode(function (InputInterface $input, OutputInterface $output): int {
$err = $output instanceof ConsoleOutputInterface ? $output->getErrorOutput() : $output;
$templateDir = $input->getOption('template-dir');
if (!is_string($templateDir) || $templateDir === '') {
$templateDir = dirname(__DIR__) . '/templates';
}

foreach (['output-dir', 'release-version', 'release-tag'] as $required) {
$value = $input->getOption($required);
if (!is_string($value) || $value === '') {
$err->writeln(sprintf('<error>--%s is required</error>', $required));
return 1;
}
}
/** @var string $outputDir */
$outputDir = $input->getOption('output-dir');
/** @var string $version */
$version = $input->getOption('release-version');
/** @var string $tag */
$tag = $input->getOption('release-tag');
$forumUrl = $input->getOption('forum-url');
if (!is_string($forumUrl) || $forumUrl === '') {
$forumUrl = AnnouncementRenderer::FORUM_URL_PLACEHOLDER;
}

$rendered = (new AnnouncementStepSummaryRenderer($templateDir))->render($outputDir, $version, $tag, $forumUrl);

$target = $input->getOption('output');
if (is_string($target) && $target !== '') {
file_put_contents($target, $rendered);
return 0;
}
$output->write($rendered);
return 0;
})
->run();
Loading