diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000000000000000000000000000000000000..63a49ec1622972a2a7a0413b1928e42b219035e1
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,2 @@
+/.cache/
+/.idea/
diff --git a/bin/glr b/bin/glr
new file mode 100755
index 0000000000000000000000000000000000000000..04eb259d8e8420e6054f7e9dd38278a024bfc64f
--- /dev/null
+++ b/bin/glr
@@ -0,0 +1,60 @@
+#!/usr/bin/env pogo --dl=_SCRIPTDIR_/../.cache/glr
+<?php
+namespace Clippy;
+
+// About: Publish a release on Gitlab
+// Usage: ./glr upload <project-url> <version> <asset-files>*
+// Example: ./glr https://lab.civicrm.org/foo/bar 1.2.3 foobar-1.2.3.zip
+
+require_once pogo_script_dir() . '/../src/clippy.php';
+
+#!require { mnapoli/silly: ~1.7, php: '>=7.0', guzzlehttp/guzzle: ~6.0 }
+use Symfony\Component\Console\Style\SymfonyStyle;
+
+function labProject($project, $io) {
+  assertThat(preg_match(';https?://[^/]+/[^/]+/[^/]+;', $project), "Project URL should match pattern: https:///DOMAIN/OWNER/REPO");
+  list ($scheme, , $host, $owner, $repo) = explode('/', $project);
+
+  $lab = new \GuzzleHttp\Client([
+    'base_uri' => "{$scheme}//{$host}/api/v4/projects/{$owner}%2F{$repo}/",
+    'headers' => [
+      'PRIVATE-TOKEN' => assertVal(cred('PRIVATE_TOKEN', $host, $io), 'Missing PRIVATE_TOKEN'),
+    ],
+  ]);
+  return $lab;
+}
+
+$app = new \Silly\Application();
+$app->command('upload projectUrl verNum assets*', function ($projectUrl, $verNum, $assets, SymfonyStyle $io) {
+  $labProject = labProject($projectUrl, $io);
+  assertThat(preg_match('/^\d[0-9a-z\.\-\+]*$/', $verNum));
+  $io->writeln(sprintf("<info>Release project <comment>%s</comment> at version <comment>%s</comment>:\n<comment>  * %s</comment></info>", $projectUrl, $verNum, implode("\n  * ", $assets)));
+
+  $existingAssets = fromJSON($labProject->get('releases/' . urlencode($verNum) . '/assets/links'));
+  $existingAssets = index(['name'], $existingAssets);
+
+  foreach ($assets as $asset) {
+    assertThat(file_exists($asset), "File $asset does not exist");
+    $upload = fromJSON($labProject->post('uploads', [
+      'multipart' => [
+        ['name' => 'file', 'contents' => fopen($asset, 'r')],
+      ],
+    ]));
+    $io->writeln("<info>Created new upload:</info>\n" . toJSON($upload));
+
+    if (isset($existingAssets[basename($asset)])) {
+      $delete = fromJSON($labProject->delete('releases/' . urlencode($verNum) . '/assets/links/' . $existingAssets[basename($asset)]['id']));
+      $io->writeln("<info>Deleted old upload:</info>\n" . toJSON($delete));
+      // Should we also delete the previous upload? Is that possible?
+    }
+
+    $release = fromJSON($labProject->post('releases/' . urlencode($verNum) . '/assets/links', [
+      'form_params' => [
+        'name' => basename($asset),
+        'url' => joinUrl($projectUrl, $upload['url']),
+      ],
+    ]));
+    $io->writeln("<info>Updated release:</info>\n" . toJSON($release));
+  }
+});;
+$app->run();
diff --git a/src/clippy.php b/src/clippy.php
new file mode 100644
index 0000000000000000000000000000000000000000..c6f2fdf9a6be66a9ab42f9a43fb7cc6ddaacea0f
--- /dev/null
+++ b/src/clippy.php
@@ -0,0 +1,177 @@
+<?php
+namespace Clippy;
+
+use Symfony\Component\Console\Style\SymfonyStyle;
+
+// -----------------------------------------------------------------------------
+// Assertions
+
+/**
+ * Assert that $bool is true.
+ *
+ * @param bool $bool
+ * @param string $msg
+ * @throws \Exception
+ */
+function assertThat($bool, $msg = '') {
+  if (!$bool) {
+    throw new \Exception($msg ? $msg : 'Assertion failed');
+  }
+}
+
+/**
+ * Assert that $value has an actual value (not null or empty-string)
+ *
+ * @param mixed $value
+ * @param string $msg
+ * @return mixed
+ *   The approved value
+ * @throws \Exception
+ */
+function assertVal($value, $msg) {
+  if ($value === NULL || $value === '') {
+    throw new \Exception($msg ? $msg : 'Missing expected value');
+  }
+  return $value;
+}
+
+function fail($msg) {
+  throw new \Exception($msg ? $msg : 'Assertion failed');
+}
+
+// -----------------------------------------------------------------------------
+// IO utilities
+
+/**
+ * Combine all elements of part, in order, to form a string - using path delimiters.
+ * Duplicate delimiters are trimmed.
+ *
+ * @param array $parts
+ *   A list of strings and/or arrays.
+ * @return string
+ */
+function joinPath(...$parts) {
+  $path = [];
+  foreach ($parts as $part) {
+    if (is_array($part)) {
+      $path = array_merge($path, $part);
+    }
+    else {
+      $path[] = $part;
+    }
+  }
+  $result = implode(DIRECTORY_SEPARATOR, $parts);
+  $both = "[\\/]";
+  return preg_replace(";{$both}{$both}+;", '/', $result);
+}
+
+/**
+ * Combine all elements of part, in order, to form a string - using URL delimiters.
+ * Duplicate delimiters are trimmed.
+ *
+ * @param array $parts
+ *   A list of strings and/or arrays.
+ * @return string
+ */
+function joinUrl(...$parts) {
+  $path = [];
+  foreach ($parts as $part) {
+    if (is_array($part)) {
+      $path = array_merge($path, $part);
+    }
+    else {
+      $path[] = $part;
+    }
+  }
+  $result = implode('/', $parts);
+  return preg_replace(';//+;', '/', $result);
+}
+
+function toJSON($data) {
+  return json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
+}
+
+function fromJSON($data) {
+  #!require psr/http-message: *
+  if ($data instanceof \Psr\Http\Message\ResponseInterface) {
+    $data = $data->getBody()->getContents();
+  }
+  assertThat(is_string($data));
+  $result = json_decode($data, 1);
+  assertThat($result !== NULL || $data === 'null', sprintf("JSON parse error:\n----\n%s\n----\n", $data));
+  return $result;
+}
+
+// -----------------------------------------------------------------------------
+// Array utilities
+
+/**
+ * Builds an array-tree which indexes the records in an array.
+ *
+ * @param string[] $keys
+ *   Properties by which to index.
+ * @param object|array $records
+ *
+ * @return array
+ *   Multi-dimensional array, with one layer for each key.
+ */
+function index($keys, $records) {
+  $final_key = array_pop($keys);
+
+  $result = [];
+  foreach ($records as $record) {
+    $node = &$result;
+    foreach ($keys as $key) {
+      if (is_array($record)) {
+        $keyvalue = isset($record[$key]) ? $record[$key] : NULL;
+      }
+      else {
+        $keyvalue = isset($record->{$key}) ? $record->{$key} : NULL;
+      }
+      if (isset($node[$keyvalue]) && !is_array($node[$keyvalue])) {
+        $node[$keyvalue] = [];
+      }
+      $node = &$node[$keyvalue];
+    }
+    if (is_array($record)) {
+      $node[$record[$final_key]] = $record;
+    }
+    else {
+      $node[$record->{$final_key}] = $record;
+    }
+  }
+  return $result;
+}
+
+// -----------------------------------------------------------------------------
+// Higher level services
+
+/**
+ * @param string $name
+ *   Environment variable
+ * @param string $context
+ * @param \Symfony\Component\Console\Style\SymfonyStyle|NULL $io
+ * @return mixed|null|string
+ */
+function cred($name, $context = 'default', SymfonyStyle $io = NULL) {
+  if (getenv($name)) {
+    return getenv($name);
+  }
+  $storage = joinPath(getenv('HOME'), '.config', 'clippy-cred', urlencode($context) . '.json');
+  if (file_exists($storage)) {
+    $data = fromJSON(file_get_contents($storage));
+    if (isset($data[$name])) {
+      return $data[$name];
+    }
+  }
+  if ($io) {
+    if ($storage) {
+      $io->note("Credential $name not found in environment");
+      $io->note("Credential $name not found in $storage");
+    }
+    $pass = $io->askHidden(sprintf('Please enter credential %s for %s:', $name, $context));
+    // TODO save
+    return $pass;
+  }
+  return NULL;
+}